poetry-core 0.0.2

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.
Files changed (166) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/DESIGN.md +145 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +30 -0
  6. data/THIRD_PARTY_NOTICES.md +507 -0
  7. data/app/components/poetry/core/box/component.rb +82 -0
  8. data/app/components/poetry/core/component.rb +390 -0
  9. data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
  10. data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
  11. data/app/components/poetry/core/concerns/introspection.rb +533 -0
  12. data/app/components/poetry/core/concerns/options.rb +262 -0
  13. data/app/components/poetry/core/concerns/parts.rb +148 -0
  14. data/app/components/poetry/core/concerns/stimulus.rb +363 -0
  15. data/app/components/poetry/core/concerns/styles.rb +323 -0
  16. data/app/components/poetry/core/style.rb +150 -0
  17. data/app/components/poetry/core/wrapper/component.rb +90 -0
  18. data/app/javascript/poetry/core/accordion_controller.js +165 -0
  19. data/app/javascript/poetry/core/action_bar_controller.js +109 -0
  20. data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
  21. data/app/javascript/poetry/core/calendar_controller.js +406 -0
  22. data/app/javascript/poetry/core/carousel_controller.js +167 -0
  23. data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
  24. data/app/javascript/poetry/core/checked_controller.js +208 -0
  25. data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
  26. data/app/javascript/poetry/core/combobox_controller.js +1095 -0
  27. data/app/javascript/poetry/core/command_controller.js +510 -0
  28. data/app/javascript/poetry/core/context_menu_controller.js +204 -0
  29. data/app/javascript/poetry/core/date_field_controller.js +584 -0
  30. data/app/javascript/poetry/core/date_picker_controller.js +111 -0
  31. data/app/javascript/poetry/core/deferred_controller.js +111 -0
  32. data/app/javascript/poetry/core/dialog_controller.js +206 -0
  33. data/app/javascript/poetry/core/dismissable_controller.js +182 -0
  34. data/app/javascript/poetry/core/drawer_controller.js +365 -0
  35. data/app/javascript/poetry/core/file_input_controller.js +191 -0
  36. data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
  37. data/app/javascript/poetry/core/helpers/announce.js +228 -0
  38. data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
  39. data/app/javascript/poetry/core/helpers/collection.js +18 -0
  40. data/app/javascript/poetry/core/helpers/direction.js +15 -0
  41. data/app/javascript/poetry/core/helpers/escape.js +38 -0
  42. data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
  43. data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
  44. data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
  45. data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
  46. data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
  47. data/app/javascript/poetry/core/helpers/mask.js +283 -0
  48. data/app/javascript/poetry/core/helpers/portal.js +217 -0
  49. data/app/javascript/poetry/core/helpers/presence.js +210 -0
  50. data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
  51. data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
  52. data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
  53. data/app/javascript/poetry/core/helpers/state.js +80 -0
  54. data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
  55. data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
  56. data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
  57. data/app/javascript/poetry/core/hotkey_controller.js +55 -0
  58. data/app/javascript/poetry/core/hover_card_controller.js +431 -0
  59. data/app/javascript/poetry/core/index.js +218 -0
  60. data/app/javascript/poetry/core/mask_controller.js +500 -0
  61. data/app/javascript/poetry/core/menu_controller.js +1050 -0
  62. data/app/javascript/poetry/core/menubar_controller.js +329 -0
  63. data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
  64. data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
  65. data/app/javascript/poetry/core/number_field_controller.js +404 -0
  66. data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
  67. data/app/javascript/poetry/core/otp_controller.js +219 -0
  68. data/app/javascript/poetry/core/popover_controller.js +300 -0
  69. data/app/javascript/poetry/core/popper_controller.js +439 -0
  70. data/app/javascript/poetry/core/pressed_controller.js +74 -0
  71. data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
  72. data/app/javascript/poetry/core/radio_group_controller.js +197 -0
  73. data/app/javascript/poetry/core/resizable_controller.js +202 -0
  74. data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
  75. data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
  76. data/app/javascript/poetry/core/search_field_controller.js +95 -0
  77. data/app/javascript/poetry/core/select_controller.js +924 -0
  78. data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
  79. data/app/javascript/poetry/core/sheet_controller.js +24 -0
  80. data/app/javascript/poetry/core/sidebar_controller.js +272 -0
  81. data/app/javascript/poetry/core/slider_controller.js +419 -0
  82. data/app/javascript/poetry/core/state_controller.js +82 -0
  83. data/app/javascript/poetry/core/table_selection_controller.js +153 -0
  84. data/app/javascript/poetry/core/tabs_controller.js +151 -0
  85. data/app/javascript/poetry/core/tag_group_controller.js +134 -0
  86. data/app/javascript/poetry/core/toast_controller.js +228 -0
  87. data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
  88. data/app/javascript/poetry/core/toaster_controller.js +197 -0
  89. data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
  90. data/app/javascript/poetry/core/tooltip_controller.js +541 -0
  91. data/app/javascript/poetry/core/tree_controller.js +265 -0
  92. data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
  93. data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
  94. data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
  95. data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
  96. data/app/views/poetry/core/preview.html.erb +13 -0
  97. data/config/component_registry.yml +20 -0
  98. data/config/controllers_manifest.json +1528 -0
  99. data/config/importmap.rb +11 -0
  100. data/config/state_vocabulary.json +31 -0
  101. data/lib/active_model/type/list.rb +33 -0
  102. data/lib/active_model/type/symbol.rb +36 -0
  103. data/lib/poetry/core/check/stable_identity.rb +104 -0
  104. data/lib/poetry/core/check.rb +1445 -0
  105. data/lib/poetry/core/config.rb +286 -0
  106. data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
  107. data/lib/poetry/core/css/bem_merger.rb +45 -0
  108. data/lib/poetry/core/css/bem_reference.rb +87 -0
  109. data/lib/poetry/core/css/override_scan.rb +110 -0
  110. data/lib/poetry/core/css/resolver.rb +161 -0
  111. data/lib/poetry/core/css/safelist.rb +41 -0
  112. data/lib/poetry/core/css/tailwind_merger.rb +102 -0
  113. data/lib/poetry/core/css/template_classes.rb +102 -0
  114. data/lib/poetry/core/css/theme_coverage.rb +72 -0
  115. data/lib/poetry/core/css/var_coverage.rb +75 -0
  116. data/lib/poetry/core/css/verifier.rb +106 -0
  117. data/lib/poetry/core/design_lint.rb +720 -0
  118. data/lib/poetry/core/design_md/import.rb +250 -0
  119. data/lib/poetry/core/design_md.rb +461 -0
  120. data/lib/poetry/core/engine.rb +71 -0
  121. data/lib/poetry/core/errors.rb +53 -0
  122. data/lib/poetry/core/html/attributes.rb +667 -0
  123. data/lib/poetry/core/icons.rb +157 -0
  124. data/lib/poetry/core/llms_text.rb +354 -0
  125. data/lib/poetry/core/page_architectures.rb +304 -0
  126. data/lib/poetry/core/part_contract.rb +285 -0
  127. data/lib/poetry/core/preview/abstract.rb +121 -0
  128. data/lib/poetry/core/preview/base.rb +227 -0
  129. data/lib/poetry/core/preview/sidecarable.rb +136 -0
  130. data/lib/poetry/core/preview/template.rb +186 -0
  131. data/lib/poetry/core/recipe_items.rb +82 -0
  132. data/lib/poetry/core/registry.rb +363 -0
  133. data/lib/poetry/core/registry_address.rb +115 -0
  134. data/lib/poetry/core/registry_client.rb +217 -0
  135. data/lib/poetry/core/registry_installer.rb +234 -0
  136. data/lib/poetry/core/registry_items.rb +184 -0
  137. data/lib/poetry/core/skill_text.rb +288 -0
  138. data/lib/poetry/core/stable_id.rb +110 -0
  139. data/lib/poetry/core/stimulus/builder.rb +395 -0
  140. data/lib/poetry/core/stimulus/declarations.rb +444 -0
  141. data/lib/poetry/core/stimulus/manifest.rb +68 -0
  142. data/lib/poetry/core/stimulus/merger.rb +131 -0
  143. data/lib/poetry/core/stimulus_contract.rb +263 -0
  144. data/lib/poetry/core/tag_helper.rb +10 -0
  145. data/lib/poetry/core/template_compile.rb +74 -0
  146. data/lib/poetry/core/token_import.rb +317 -0
  147. data/lib/poetry/core/tokens/color.rb +215 -0
  148. data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
  149. data/lib/poetry/core/tokens/generator.rb +211 -0
  150. data/lib/poetry/core/tokens.rb +117 -0
  151. data/lib/poetry/core/version.rb +9 -0
  152. data/lib/poetry/core.rb +52 -0
  153. data/lib/poetry-core.rb +5 -0
  154. data/tokens/aliases.css +145 -0
  155. data/tokens/tailwind-theme.css +47 -0
  156. data/tokens/tokens.css +78 -0
  157. data/tokens/tokens.dtcg.json +779 -0
  158. data/vendor/floating-ui/LICENSE +20 -0
  159. data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
  160. data/vendor/shadcn-tailwind/LICENSE +21 -0
  161. data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
  162. data/vendor/shadcn-tailwind/tailwind.css +629 -0
  163. data/vendor/tw-animate-css/LICENSE +21 -0
  164. data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
  165. data/vendor/tw-animate-css/tw-animate.css +1 -0
  166. metadata +267 -0
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ module Preview
6
+ # Provides functionality to mark preview classes as abstract and exclude them from the preview index.
7
+ # Adapted from an MIT-licensed source (source and license in
8
+ # THIRD_PARTY_NOTICES.md).
9
+ #
10
+ # This module adds an `abstract_class` accessor to preview classes and automatically filters
11
+ # them out when listing all available previews. This is particularly useful for creating base
12
+ # preview classes that provide shared functionality but shouldn't appear in the preview UI.
13
+ #
14
+ # The module works by extending preview classes (typically ViewComponent::Preview) and prepending
15
+ # class methods that add the abstract_class attribute and modify the `all` method to exclude
16
+ # abstract previews from the results.
17
+ #
18
+ # @example Marking a base preview class as abstract
19
+ # class ApplicationPreview < ViewComponent::Preview
20
+ # extend Poetry::Core::Preview::Abstract
21
+ # self.abstract_class = true
22
+ #
23
+ # # Shared helper methods for all previews
24
+ # def render_in_container(&block)
25
+ # content_tag(:div, class: "preview-container", &block)
26
+ # end
27
+ # end
28
+ #
29
+ # class ButtonPreview < ApplicationPreview
30
+ # # This will appear in the preview index
31
+ # def default
32
+ # render ButtonComponent.new
33
+ # end
34
+ # end
35
+ #
36
+ # ViewComponent::Preview.all
37
+ # # => [ButtonPreview] (ApplicationPreview is excluded)
38
+ #
39
+ # @example Using with Poetry::Core::Preview::Base
40
+ # # Poetry::Core::Preview::Base automatically uses this module
41
+ # class MyBasePreview < Poetry::Core::Preview::Base
42
+ # self.abstract_class = true
43
+ #
44
+ # def helper_method
45
+ # "shared logic"
46
+ # end
47
+ # end
48
+ #
49
+ # class ConcretePreview < MyBasePreview
50
+ # def example
51
+ # render SomeComponent.new
52
+ # end
53
+ # end
54
+ #
55
+ # @example Checking if a preview is abstract
56
+ # ApplicationPreview.abstract_class? # => true
57
+ # ButtonPreview.abstract_class? # => false (or nil)
58
+ #
59
+ # @note This module is automatically applied to ViewComponent::Preview when Poetry::Core::Preview::Base
60
+ # is loaded, so abstract_class functionality is available throughout the preview system.
61
+ #
62
+ module Abstract
63
+ # Hook method called when this module is extended into a class.
64
+ #
65
+ # Prepends ClassMethods to the class's singleton class, making the abstract_class
66
+ # functionality available to the extending class and overriding its `all` method.
67
+ #
68
+ # @param base [Class] the class being extended with this module
69
+ # @return [void]
70
+ # @api private
71
+ def self.extended(base)
72
+ base.singleton_class.prepend(ClassMethods)
73
+ end
74
+
75
+ # Class methods added to preview classes that extend the Abstract module.
76
+ #
77
+ # These methods provide the abstract_class attribute and modify preview collection
78
+ # behavior to automatically filter out abstract classes.
79
+ module ClassMethods
80
+ # @!attribute [rw] abstract_class
81
+ # @return [Boolean, nil] whether this preview class is abstract
82
+ attr_accessor :abstract_class
83
+
84
+ # Checks if this preview class is marked as abstract.
85
+ #
86
+ # @return [Boolean, nil] true if abstract, false or nil otherwise
87
+ # @example
88
+ # MyPreview.abstract_class = true
89
+ # MyPreview.abstract_class? # => true
90
+ alias abstract_class? abstract_class
91
+
92
+ # Returns all non-abstract descendant preview classes.
93
+ #
94
+ # This method overrides the default ViewComponent::Preview.all to exclude
95
+ # any preview classes marked as abstract. It ensures previews are loaded
96
+ # before filtering if no descendants exist yet.
97
+ #
98
+ # @return [Array<Class>] array of non-abstract preview classes
99
+ # @example
100
+ # class BasePreview < ViewComponent::Preview
101
+ # extend Poetry::Core::Preview::Abstract
102
+ # self.abstract_class = true
103
+ # end
104
+ #
105
+ # class ButtonPreview < BasePreview; end
106
+ # class CardPreview < BasePreview; end
107
+ #
108
+ # ViewComponent::Preview.all
109
+ # # => [ButtonPreview, CardPreview] (BasePreview excluded)
110
+ def all
111
+ # Loading delegates to Sidecarable#load_previews when present
112
+ # (poetry's engine wires both onto ViewComponent::Preview);
113
+ # standalone, already-loaded descendants are filtered as-is.
114
+ load_previews if respond_to?(:load_previews) && descendants.reject(&:abstract_class?).empty?
115
+ descendants.reject(&:abstract_class?)
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ # The preview stack: base class, template resolution, and gallery glue.
6
+ # Adapted from an MIT-licensed source (source and license in
7
+ # THIRD_PARTY_NOTICES.md).
8
+ module Preview
9
+ # First, enable abstract classes (if not already extended)
10
+ unless ViewComponent::Preview.singleton_class.is_a?(Preview::Abstract)
11
+ ViewComponent::Preview.extend Preview::Abstract
12
+ end
13
+
14
+ # Enhanced base class for ViewComponent previews with additional features.
15
+ #
16
+ # Poetry::Core::Preview::Base extends the standard ViewComponent::Preview with several enhancements:
17
+ # - Automatic component class inference from preview class name
18
+ # - Template rendering support with custom layouts
19
+ # - Container class configuration with inheritance
20
+ # - Convenient shortcut methods for rendering components
21
+ #
22
+ # Theme and mode switching in Lookbook is a LAYOUT concern, not a
23
+ # preview-class one: the preview layout reads
24
+ # `lookbook_display(:theme)` / `lookbook_display(:mode)` and stamps
25
+ # classes on <html>; components stay theme-agnostic by construction.
26
+ #
27
+ # @example Basic usage
28
+ # class ButtonPreview < Poetry::Core::Preview::Base
29
+ # # Automatically infers Button::Component, ButtonComponent, or Button
30
+ # def default
31
+ # render_component(text: "Click me", variant: :primary)
32
+ # end
33
+ #
34
+ # def with_custom_container
35
+ # self.class.container_class = "p-4 bg-gray-100"
36
+ # render_component(text: "Styled container")
37
+ # end
38
+ # end
39
+ #
40
+ # @example Using render_with for template locals
41
+ # class CardPreview < Poetry::Core::Preview::Base
42
+ # def default
43
+ # render_with(title: "Card Title", description: "Card description")
44
+ # end
45
+ # end
46
+ #
47
+ # @example Passing a component instance directly
48
+ # class AlertPreview < Poetry::Core::Preview::Base
49
+ # def warning
50
+ # component = AlertComponent.new(type: :warning, dismissible: true)
51
+ # render_component(component)
52
+ # end
53
+ # end
54
+ #
55
+ class Base < ViewComponent::Preview
56
+ self.abstract_class = true
57
+
58
+ include Poetry::Core::Preview::Template
59
+
60
+ # The container class previews render inside when neither the
61
+ # preview class nor an ancestor sets one.
62
+ DEFAULT_CONTAINER_CLASS = ""
63
+
64
+ class << self
65
+ # Ensures child preview classes inherit layout configuration.
66
+ #
67
+ # @param child [Class] the inheriting preview class
68
+ # @return [void]
69
+ # @api private
70
+ def inherited(child)
71
+ child.layout(@layout) if defined?(@layout)
72
+ super
73
+ end
74
+
75
+ attr_writer :container_class, :component_class_name
76
+
77
+ # Returns the configured container CSS class for the preview.
78
+ #
79
+ # Container classes are inherited from parent preview classes. If not explicitly set,
80
+ # uses the parent's container class or DEFAULT_CONTAINER_CLASS.
81
+ #
82
+ # @return [String] the container CSS class
83
+ # @example
84
+ # class MyPreview < Poetry::Core::Preview::Base
85
+ # self.container_class = "p-8 bg-white"
86
+ # end
87
+ #
88
+ # MyPreview.container_class # => "p-8 bg-white"
89
+ def container_class
90
+ return @container_class if defined?(@container_class)
91
+
92
+ @container_class =
93
+ if superclass.respond_to?(:container_class)
94
+ superclass.container_class
95
+ else
96
+ DEFAULT_CONTAINER_CLASS
97
+ end
98
+ end
99
+
100
+ # Prepares render arguments by building component instance and setting container class.
101
+ #
102
+ # @return [Hash] render arguments with locals configured
103
+ # @api private
104
+ def render_args(...)
105
+ super.tap do |res|
106
+ res[:locals] ||= {}
107
+ build_component_instance(res[:locals])
108
+ res[:locals][:container_class] ||= container_class
109
+ end
110
+ end
111
+
112
+ # Infers the component class name from the preview class name.
113
+ #
114
+ # Tries multiple naming conventions in order:
115
+ # - Namespace::ButtonPreview => Namespace::Button::Component
116
+ # - Namespace::ButtonPreview => Namespace::ButtonComponent
117
+ # - Namespace::ButtonPreview => Namespace::Button
118
+ # - Button::Preview => Button::Component | ButtonComponent | Button
119
+ #
120
+ # @return [String, nil] the inferred component class name or nil if not found
121
+ # @example
122
+ # class MyApp::ButtonPreview < Poetry::Core::Preview::Base
123
+ # end
124
+ #
125
+ # MyApp::ButtonPreview.component_class_name
126
+ # # => "MyApp::Button::Component" (if it exists)
127
+ # # or "MyApp::ButtonComponent" (if it exists)
128
+ # # or "MyApp::Button" (if it exists)
129
+ def component_class_name
130
+ @component_class_name ||= begin
131
+ component_name = name.sub(/(::Preview|Preview)$/, "")
132
+ [
133
+ "#{component_name}::Component",
134
+ "#{component_name}Component",
135
+ component_name
136
+ ].find(&:safe_constantize)
137
+ end
138
+ end
139
+
140
+ private
141
+
142
+ # Builds a component instance and adds it to locals.
143
+ #
144
+ # If component is already present in locals, returns unchanged.
145
+ # If component class cannot be instantiated, sets error message in locals.
146
+ #
147
+ # @param locals [Hash] the locals hash to modify
148
+ # @return [Hash] the modified locals hash
149
+ # @api private
150
+ def build_component_instance(locals)
151
+ return locals unless locals[:component].nil?
152
+
153
+ locals[:component] = component_class_name.safe_constantize&.new
154
+ rescue StandardError => e
155
+ locals[:component] = nil
156
+ locals[:error] = e.message
157
+ end
158
+ end
159
+
160
+ # Renders a NESTED component inside a slot/content block. Plain
161
+ # render() there resolves to the preview DSL's own render (a
162
+ # declaration returning a hash) and the nested component silently
163
+ # vanishes - button icons, card actions, dialog footer buttons were
164
+ # all missing from every preview until the a11y preview rig caught it.
165
+ #
166
+ # @param component [ViewComponent::Base] the component instance to render
167
+ # @return [String] the rendered HTML
168
+ def embed(component, &)
169
+ ApplicationController.new.view_context.render(component, &)
170
+ end
171
+
172
+ # Renders the preview with custom template locals.
173
+ #
174
+ # This is a convenience method that wraps render_with_template, making it easier
175
+ # to pass locals to your preview templates.
176
+ #
177
+ # @param locals [Hash] key-value pairs to pass as local variables to the template
178
+ # @return [String] the rendered template
179
+ # @example
180
+ # def default
181
+ # render_with(title: "Hello", message: "World")
182
+ # end
183
+ def render_with(**locals)
184
+ render_with_template(locals: locals)
185
+ end
186
+
187
+ # Renders a component instance or creates one from props.
188
+ #
189
+ # This method provides a convenient way to render components in previews. It can accept
190
+ # either a component instance directly, or a hash of props to instantiate the component.
191
+ # The component class is automatically inferred from the preview class name.
192
+ #
193
+ # @param component_or_props [ViewComponent::Base, Hash, nil] either a component instance
194
+ # or a hash of props to pass to the component constructor
195
+ # @param block [Proc, nil] optional block to pass as content to the component
196
+ # @yield the component's content
197
+ # @return [String] the rendered component
198
+ # @example With props hash
199
+ # def default
200
+ # render_component(variant: :primary, text: "Click me")
201
+ # end
202
+ #
203
+ # @example With component instance
204
+ # def custom
205
+ # component = ButtonComponent.new(variant: :primary)
206
+ # render_component(component)
207
+ # end
208
+ #
209
+ # @example With content block
210
+ # def with_content
211
+ # render_component(variant: :primary) do
212
+ # "Custom content"
213
+ # end
214
+ # end
215
+ def render_component(component_or_props = nil, &block)
216
+ component = if component_or_props.is_a?(::ViewComponent::Base)
217
+ component_or_props
218
+ else
219
+ self.class.component_class_name.constantize.new(**(component_or_props || {}))
220
+ end
221
+
222
+ render_with(component: component, content_block: block)
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ module Preview
6
+ # Provides sidecar file loading and naming utilities for preview classes.
7
+ # Adapted from an MIT-licensed source (source and license in
8
+ # THIRD_PARTY_NOTICES.md).
9
+ #
10
+ # This module adds functionality to automatically load preview files from configured paths
11
+ # and derive component names from preview class names. It's designed to support the "sidecar"
12
+ # pattern where preview files are organized alongside their corresponding components.
13
+ #
14
+ # The module extends preview classes (typically ViewComponent::Preview) and prepends class
15
+ # methods that handle preview file discovery and name extraction, making it easier to organize
16
+ # and load previews in a consistent, convention-based manner.
17
+ #
18
+ # @example Basic usage with ViewComponent::Preview
19
+ # ViewComponent::Preview.extend Poetry::Core::Preview::Sidecarable
20
+ #
21
+ # # Load all preview files from configured paths
22
+ # ViewComponent::Preview.load_previews
23
+ #
24
+ # @example Preview name extraction
25
+ # class MyApp::Button::Preview < ViewComponent::Preview
26
+ # extend Poetry::Core::Preview::Sidecarable
27
+ # end
28
+ #
29
+ # MyApp::Button::Preview.preview_name
30
+ # # => "my_app/button"
31
+ #
32
+ # @example Namespace variations
33
+ # class ButtonPreview < ViewComponent::Preview
34
+ # extend Poetry::Core::Preview::Sidecarable
35
+ # end
36
+ #
37
+ # ButtonPreview.preview_name # => "button"
38
+ #
39
+ # class Admin::CardPreview < ViewComponent::Preview
40
+ # extend Poetry::Core::Preview::Sidecarable
41
+ # end
42
+ #
43
+ # Admin::CardPreview.preview_name # => "admin/card"
44
+ #
45
+ # @note Preview files are discovered using the glob pattern `**/{preview.rb,*_preview.rb}`,
46
+ # which matches both standalone `preview.rb` files and files ending with `_preview.rb`.
47
+ #
48
+ module Sidecarable
49
+ # Glob pattern for discovering preview files.
50
+ #
51
+ # Matches:
52
+ # - `preview.rb` - Standalone preview files
53
+ # - `*_preview.rb` - Named preview files (e.g., `button_preview.rb`)
54
+ #
55
+ # @return [String] the glob pattern for preview file discovery
56
+ PREVIEW_GLOB = "**/{preview.rb,*_preview.rb}"
57
+
58
+ # Hook method called when this module is extended into a class.
59
+ #
60
+ # Prepends ClassMethods to the class's singleton class, making the sidecar loading
61
+ # functionality available to the extending class.
62
+ #
63
+ # @param base [Class] the class being extended with this module
64
+ # @return [void]
65
+ # @api private
66
+ def self.extended(base)
67
+ base.singleton_class.prepend(ClassMethods)
68
+ end
69
+
70
+ # Class methods added to preview classes that extend the Sidecarable module.
71
+ #
72
+ # These methods provide preview file loading and component name extraction functionality.
73
+ module ClassMethods
74
+ # Loads all preview files from configured preview paths.
75
+ #
76
+ # Iterates through each configured preview path and requires all files matching
77
+ # the PREVIEW_GLOB pattern. Files are loaded in sorted order to ensure consistent
78
+ # loading sequence across environments.
79
+ #
80
+ # @return [void]
81
+ # @example Loading previews
82
+ # ViewComponent::Preview.preview_paths = ["app/components"]
83
+ # ViewComponent::Preview.load_previews
84
+ # # Loads: app/components/**/preview.rb
85
+ # # app/components/**/*_preview.rb
86
+ #
87
+ # @example With multiple paths
88
+ # ViewComponent::Preview.preview_paths = [
89
+ # "app/components",
90
+ # "app/views/components"
91
+ # ]
92
+ # ViewComponent::Preview.load_previews
93
+ # # Loads previews from both directories
94
+ #
95
+ # @note Uses `require_dependency` to ensure proper reloading in development mode.
96
+ def load_previews
97
+ Array(preview_paths).each do |preview_path|
98
+ Dir["#{preview_path}/#{PREVIEW_GLOB}"].each { |file| require_dependency file }
99
+ end
100
+ end
101
+
102
+ # Extracts the component name from the preview class name.
103
+ #
104
+ # Removes the "Preview" or "::Preview" suffix from the class name and converts
105
+ # it to underscore/snake_case format. This is useful for deriving file paths,
106
+ # identifiers, and component names from preview class names.
107
+ #
108
+ # @return [String] the underscored component name
109
+ # @example Basic usage
110
+ # class ButtonPreview < ViewComponent::Preview
111
+ # extend Poetry::Core::Preview::Sidecarable
112
+ # end
113
+ #
114
+ # ButtonPreview.preview_name # => "button"
115
+ #
116
+ # @example With namespace
117
+ # class Admin::Dashboard::CardPreview < ViewComponent::Preview
118
+ # extend Poetry::Core::Preview::Sidecarable
119
+ # end
120
+ #
121
+ # Admin::Dashboard::CardPreview.preview_name # => "admin/dashboard/card"
122
+ #
123
+ # @example With trailing Preview namespace
124
+ # class Components::Alert::Preview < ViewComponent::Preview
125
+ # extend Poetry::Core::Preview::Sidecarable
126
+ # end
127
+ #
128
+ # Components::Alert::Preview.preview_name # => "components/alert"
129
+ def preview_name
130
+ name.sub(/(::Preview|Preview)$/, "").underscore
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ module Preview
6
+ # Provides flexible template resolution for ViewComponent previews.
7
+ # Adapted from an MIT-licensed source (source and license in
8
+ # THIRD_PARTY_NOTICES.md).
9
+ #
10
+ # This module extends ViewComponent's preview template system to support multiple
11
+ # template locations and fallback strategies. It allows previews to use:
12
+ # - Example-specific templates (e.g., `previews/default.html.erb`)
13
+ # - Shared preview templates (e.g., `preview.html.erb`)
14
+ # - A configurable default template
15
+ #
16
+ # The template resolution follows this priority order:
17
+ # 1. ViewComponent's standard template location (via super)
18
+ # 2. Example-specific template: `preview_name/previews/example_name.html.*`
19
+ # 3. Preview-level template: `preview_name/preview.html.*`
20
+ # 4. Default template (configurable, defaults to "poetry/core/preview")
21
+ #
22
+ # @example Basic usage with default template
23
+ # class ButtonPreview < Poetry::Core::Preview::Base
24
+ # # Uses the default template (poetry/core/preview)
25
+ # def default
26
+ # render_component(text: "Click me")
27
+ # end
28
+ # end
29
+ #
30
+ # @example Using example-specific template
31
+ # # File: test/components/previews/button_preview.rb
32
+ # class ButtonPreview < Poetry::Core::Preview::Base
33
+ # def complex
34
+ # render_with(button: ButtonComponent.new)
35
+ # end
36
+ # end
37
+ #
38
+ # # Template: test/components/previews/button_preview/previews/complex.html.erb
39
+ # <div class="preview-container">
40
+ # <%= render @button %>
41
+ # <p>Additional markup for this specific preview</p>
42
+ # </div>
43
+ #
44
+ # @example Using shared preview template
45
+ # # File: test/components/previews/card_preview.rb
46
+ # class CardPreview < Poetry::Core::Preview::Base
47
+ # def basic; render_component; end
48
+ # def featured; render_component(featured: true); end
49
+ # end
50
+ #
51
+ # # Template: test/components/previews/card_preview/preview.html.erb
52
+ # # This template is shared by all examples in this preview
53
+ # <div class="card-demo-wrapper">
54
+ # <%= render @component %>
55
+ # </div>
56
+ #
57
+ # @example Configuring custom default template
58
+ # class MyBasePreview < Poetry::Core::Preview::Base
59
+ # self.default_preview_template = "my_app/custom_preview"
60
+ # end
61
+ #
62
+ # class ButtonPreview < MyBasePreview
63
+ # # Inherits the custom default template
64
+ # def default
65
+ # render_component
66
+ # end
67
+ # end
68
+ #
69
+ # @see Poetry::Core::Preview::Base
70
+ module Template
71
+ # The default template path used when no specific template is found.
72
+ #
73
+ # This template is rendered for previews that don't have:
74
+ # - A ViewComponent standard template
75
+ # - An example-specific template
76
+ # - A preview-level shared template
77
+ #
78
+ # @api public
79
+ DEFAULT_TEMPLATE = "poetry/core/preview"
80
+
81
+ # @api private
82
+ def self.included(base)
83
+ base.singleton_class.prepend(ClassMethods)
84
+ end
85
+
86
+ # Class-level methods added to preview classes when Template is included.
87
+ #
88
+ # These methods provide template configuration and resolution logic that
89
+ # operates at the class level, allowing inheritance and customization.
90
+ module ClassMethods
91
+ # @!attribute [w] default_preview_template
92
+ # Sets the default template path for this preview class.
93
+ #
94
+ # @return [String] the configured template path
95
+ #
96
+ # @example
97
+ # class MyPreview < Poetry::Core::Preview::Base
98
+ # self.default_preview_template = "my_app/special_preview"
99
+ # end
100
+ attr_writer :default_preview_template
101
+
102
+ # Returns the default preview template path for this class.
103
+ #
104
+ # The template path is inherited from parent classes. If not explicitly set,
105
+ # it walks up the class hierarchy until it finds a configured template or
106
+ # falls back to {DEFAULT_TEMPLATE}.
107
+ #
108
+ # @return [String] the default template path
109
+ #
110
+ # @example Using default template
111
+ # Poetry::Core::Preview::Base.default_preview_template
112
+ # # => "poetry/core/preview"
113
+ #
114
+ # @example Custom template with inheritance
115
+ # class BasePreview < Poetry::Core::Preview::Base
116
+ # self.default_preview_template = "custom/layout"
117
+ # end
118
+ #
119
+ # class ButtonPreview < BasePreview
120
+ # end
121
+ #
122
+ # ButtonPreview.default_preview_template
123
+ # # => "custom/layout" (inherited from BasePreview)
124
+ def default_preview_template
125
+ return @default_preview_template if defined?(@default_preview_template)
126
+
127
+ @default_preview_template =
128
+ if superclass.respond_to?(:default_preview_template)
129
+ superclass.default_preview_template
130
+ else
131
+ DEFAULT_TEMPLATE
132
+ end
133
+ end
134
+
135
+ # Resolves the template path for a preview example with multiple fallbacks.
136
+ #
137
+ # This method implements a flexible template resolution strategy that tries
138
+ # multiple locations in order:
139
+ #
140
+ # 1. First calls ViewComponent's default resolution (via super)
141
+ # 2. If not found, looks for example-specific template at:
142
+ # `preview_name/previews/example_name.html.*`
143
+ # 3. If not found, looks for shared preview template at:
144
+ # `preview_name/preview.html.*`
145
+ # 4. Falls back to {#default_preview_template}
146
+ #
147
+ # This fallback behavior allows previews to share templates across examples
148
+ # or use a common default template, reducing duplication.
149
+ #
150
+ # @param example [String] the name of the preview example method
151
+ # @return [String] the resolved template path
152
+ #
153
+ # @example Template resolution for ButtonPreview#primary
154
+ # # Tries in order:
155
+ # # 1. ViewComponent default (e.g., test/components/previews/button_preview/primary.html.erb)
156
+ # # 2. test/components/previews/button_preview/previews/primary.html.erb
157
+ # # 3. test/components/previews/button_preview/preview.html.erb
158
+ # # 4. the default template (poetry/core/preview)
159
+ #
160
+ # @note The method searches across all configured preview_paths
161
+ # @note Supports any template handler (erb, haml, slim, etc.)
162
+ def preview_example_template_path(example)
163
+ super
164
+ rescue ViewComponent::MissingPreviewTemplateError
165
+ # Look for example-specific template: preview_name/previews/example.html.*
166
+ has_example_preview = preview_paths.find do |path|
167
+ Dir.glob(File.join(path, preview_name, "previews", "#{example}.html.*")).any?
168
+ end
169
+
170
+ return File.join(preview_name, "previews", example) if has_example_preview
171
+
172
+ # Look for shared preview template: preview_name/preview.html.*
173
+ has_root_preview = preview_paths.find do |path|
174
+ Dir.glob(File.join(path, preview_name, "preview.html.*")).any?
175
+ end
176
+
177
+ return File.join(preview_name, "preview") if has_root_preview
178
+
179
+ # Fall back to configured default template
180
+ default_preview_template
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end