poetry-core 0.1.1 → 0.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9b4189654db2722eff128793c1bd4ce725c06a60ec1b7ff8bf29d5af7aa4e9ef
4
- data.tar.gz: fb0245f3b9abff92ad3f9bdd48d3da7a5fc421531e346d40e3e53eadaf304233
3
+ metadata.gz: 68f26084516bebd43571b5f687d9fb07cce952f00aba8e41448715b66b996fba
4
+ data.tar.gz: d1ec44154a85d4c2b4fda384432f2e73349973dfc21862aeddf06bde0d09303b
5
5
  SHA512:
6
- metadata.gz: 3c79e20564e016e6b3b7b34378bb6222ca8708279864235bfd0c8d2a7b899f56e229a53cb284dbec9b9601e91922fc6f9799353fc8c7ddac8d1fa5d8fba6ae0b
7
- data.tar.gz: 73b7dc73529fb2c1782a343ef7ddac9228591fba3b73c57163a0cfab4f6066e26da0d573447af273605e0bc98e1ff40d0fe0917a770c56e873ec22714b563182
6
+ metadata.gz: 65965b0865ade83c8b778c3ba773c6d1272bd4da2139e68cd8ab5b798903d12e3224d75df0f77f849cd72403e782d5da87ba470a4597d8654c7ab72a6bdc90ea
7
+ data.tar.gz: e3b822469b4ed1253193dd331b9b43b7923aa5626265ec9f6d1cdc6c4112a7306eb93e37f3a7670b9985da406ef804b5eb4b5174b03263004eced90e306e03c3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.2] - 2026-09-13
4
+
5
+ ### Added
6
+
7
+ - `helper :name` on a component: an application's (or an engine's) own component written on the DSL names its view helper. The engine defines that helper on Action View at boot and on every reload, and `Poetry::Core::HostComponents` discovers the app's components by convention (under `app/`, outside the `poetry/` namespace, published), builds their registry live, and reads their declared helpers boot-free from source. The registry entry carries `helper`; `poetry check` lints a declared helper under its own name (options, variants, arity, stable identity); llms.txt and the generated skill take an app registry and render an App components section and a `references/app.md`.
8
+ - The runtime values tier. A component built with an off-list style value or a missing required option raises at construction in development and test, naming the allowed values with a did-you-mean, and logs in production while rendering as before (the passthrough guard's posture, at the same seam, so it holds whatever a component does in `before_render`). Only the DSL's own validations are enforced; a component's custom validators keep their own policy. An omitted style is nil, not an off-list value (`allow_nil` on the inclusion validations). The check is a direct lookup over the declared vocabulary (`style` variants, `required`, and `validates ... inclusion: { in: [...] }` on a declared attribute), not an ActiveModel validation pass.
9
+ - Render cost. Measured on 200 Buttons in one view context, a Button went from 0.122 ms to 0.089 ms: the values tier is a direct lookup instead of a validation pass, construction converts the caller's attributes once, and the Style resolver memoises its dictionary walk per variant tuple (bounded by the variant space, cleared when the dictionary changes). The remaining time is ViewComponent's own render (about forty percent) and attribute assignment (a quarter).
10
+ - The class-name merger follows the kit's CSS mode (`Poetry::Core::CSS::Modes.merger_for`): a `:bem` kit merges with a `BemMerger`, a `:tailwind` kit with a `TailwindMerger`. The global `classname_merger` is honoured whenever it is of the right kind, so a customised Tailwind merger still reaches poetry-ui and a host's BEM merger still reaches its BEM kit; a mismatch falls back to the stock merger of the mode. A host that set the global mode to `:bem` without a merger now gets the BEM merger on its kit, the pairing the guide always asked for. Attribute objects keep their assigned merger across copies.
11
+ - `css_mode` is decided per kit. A component class declares it (`css_mode :bem`, inherited), or a kit pins its namespace (`Poetry::Core::CSS::Modes.pin("Acme::Ui", :bem)`); the global `Config.current.css_mode` is the default for kits that declare nothing, and a `css_mode:` keyword on a `css` call still wins over all of them. poetry-ui and poetry-charts pin `:tailwind`, so a host's global never reaches them and a BEM kit on the DSL renders beside them in one app. Before, the global was the only switch and setting it to `:bem` silently stripped poetry-ui's styling.
12
+ - `OverrideScan` takes `owned:`, the theme-owned `cn-*` names; only a rule against one of those is an override. A host's own `cn-*` classes (a kit's dictionary, a page helper) are never findings. Nil keeps the old meaning (every `cn-*` name).
13
+ - The host controllers manifest. `Poetry::Core::Stimulus::HostManifest` reads `app/javascript/controllers` (static targets, values in both forms, classes, `static events`, the class body's own methods) and writes `config/controllers_manifest.json`, the file a gem commits; the engine registers every loaded engine's and the app's manifest by convention before components load. A host controller then validates like poetry's: `use_stimulus` by Symbol at class load, template wiring in `poetry check` (the value-attribute rule now covers any registered controller), and its API in the registry's controllers section. The reader masks comments and string, template and regex literals before it reads structure, so a comment inside `static values`, a brace in a string, a one-line method body or an arrow-function class field all read correctly; it scrubs stray bytes and a BOM. An entry is complete or absent: a parent that is another host controller (relative import) or a poetry controller (`@poetry/controllers`, file or barrel import; charts and agent by their packages) merges child-first; an unresolvable parent, a computed static, a getter or a spread yields no entry and a named skip. A hand-written entry for a skipped identifier survives regeneration and never reads as stale (the gate compares only the controllers the reader read). Events are validated only from a literal `static events`; a controller that only calls `dispatch()` keeps them unknown, never empty. A Symbol declaration resolves to a gem's namespaced controller before a host controller of the same bare name, so a host `tabs_controller.js` never hijacks `controller :tabs`; the unknown-Symbol message names `bin/rails poetry:stimulus:manifest`. `Manifest.register_roots`, `Manifest.forget`; `Registry.gem_roots(registry: false)` lists every bundled root for the manifest walk.
14
+ - Registry roots by convention. `Poetry::Core::Registry.roots` (booted: every loaded engine whose root carries a published registry, then the app when it committed one), `.gem_roots` (boot-free, from the bundle, plus an app root), and `.merged` (one view over several roots, block templates resolved to absolute paths). A registry can mark itself `internal: true` and consumers skip it; poetry-core's own is now marked, since its building blocks have no helpers. `HostComponents.committed_state` reports the app's committed registry as missing, fresh, or stale against the live build.
15
+ - `Poetry::Core::CSS::TokenCollisions`: scans a host app's stylesheets for declarations of Poetry's token names (`--primary`, `--accent`, `--radius`, ...) and Tailwind theme keys (`--color-*`, `--radius-*`), and reports each with its location and what the role paints in Poetry's components. `poetry:install` and `poetry:check` run it.
16
+
17
+ ### Changed
18
+
19
+ - A namespace pin follows the dictionary: a component's mode is its own declaration, else the pin of the root-most pinned ancestor (`Modes.inherited_for`), else the global, so a host subclass of a poetry-ui component stays Tailwind inside a BEM-pinned namespace while a host component written on the DSL takes the pin.
20
+ - A dictionary merges by its own mode at every level: the resolver takes its class merger from the Style's sidecar component (its declaration, its kit's pin, or the global), so a host's global BEM merger never reaches a Tailwind kit's element-level joins, not only its root. A style redeclared without variants is open, whatever the parent listed: an inherited inclusion validator no longer narrows it. The "Style class not found" debug line is logged once per class.
21
+
22
+ - `poetry check` validates every descriptor of a `data-action`, not only the last (a bogus first descriptor was invisible); a `data-<controller>-<name>-value` or `-target` attribute resolves to the controller the element declares when several registered identifiers fit (`foo` and `foo-bar`), a plain `data-foo-value` is the host's own attribute, a host controller's targets validate like poetry's, and an attribute whose value carries ERB skips the wiring rules instead of type-checking its fragments. A declared app helper matches bare calls only (`u.badge` is somebody's method); the stable-identity pass agrees. A file the runner cannot read or decode is one `unreadable` warning naming it, never the end of the sweep. `Check.mail_template?(path, root:)` judges the path inside the app root (an app checked out under a folder called `acme_mailer` keeps its pages) and knows Devise's `devise/mailer/`.
23
+
24
+ - Every registry names every helper. A gem entry carries its `poetry_` convention name and an app entry the name it declared with `helper :name`; `Registry.helper_for(path, entry)` is the one resolver every surface reads (llms.txt, the check, the skill, the MCP server), so an app component that declared no helper is listed by class and no surface invents a `poetry_` name for it. The committed gem registries regenerate with the added key and nothing else.
25
+ - Host helpers are checked before any is defined: a clash (a name Action View, poetry-ui, or the app's own `ApplicationController` helpers already carry, private methods included) or two components declaring one name raises naming both sides, leaves the module untouched, and the next reload after the fix starts clean. `Registry.read_file` is the one validated reader of a committed registry file; an empty, malformed, or wrong-shaped file reads as no registry (`Registry.invalid_at?`) instead of raising in whichever surface opened it first. The boot-free `HostComponents.declared_helpers` parses component sources with Prism, so a mention in a string, heredoc, or comment is not a declaration and `helper(:x)`, `helper "x"`, and `self.helper :x` are. `poetry:registry` leaves an engine's own components to that engine's registry.
26
+
27
+ - `poetry check` leaves a mailer template's colors alone: under a `*_mailer/` directory or in the mailer layout, an inline hex or a color literal in a utility class is the only paint an email client honours (no stylesheet, no tokens), so the raw-color rule stays quiet there while every other rule runs as on a page. `Check.lint` takes `mail:` for a source string; the runner derives it from the path (`Check.mail_template?`).
28
+
29
+ - The generated Tailwind theme mapping is `@theme inline default`. A host `@theme` value for the same key (its own `--color-primary`, a `--radius-sm` it set before Poetry arrived) wins whether it is declared before or after the mapping; Poetry's value still applies wherever the host set nothing. Before, the mapping replaced the host's keys wholesale, so every `rounded-sm` in an existing app changed size.
30
+
3
31
  ## [0.1.1] - 2026-09-08
4
32
 
5
33
  ### Added
data/README.md CHANGED
@@ -20,10 +20,6 @@ After checking out the repo, run `bin/setup` to install dependencies, then `bund
20
20
 
21
21
  Releases publish to [RubyGems.org](https://rubygems.org) via GitHub Actions OIDC **trusted publishing** (no API keys). Run `bundle exec rake "version:bump[X.Y.Z]"` (sets `Poetry::Core::VERSION` and package.json together; `rake version:verify` in the default chain fails on drift), commit, then push a `vX.Y.Z` tag — the `Release` workflow checks the tag against the version and publishes the gem.
22
22
 
23
- ## Lineage
24
-
25
- poetry-core evolves the framework layer of `view_component_plus` (the `Plus` gem) — itself a synthesis of view_component-contrib and fox_tail.
26
-
27
23
  ## License
28
24
 
29
25
  Available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -54,6 +54,14 @@ module Poetry
54
54
  # a subclass of an internal component stays internal.
55
55
  class_attribute :internal_component, default: false, instance_predicate: false
56
56
 
57
+ # The declared vocabulary the runtime values tier checks:
58
+ # name => { variants: [...] or nil, required: true/false }, recorded
59
+ # by `style` and `option` at declaration (copy-on-write per class, so
60
+ # a subclass's declarations never leak up). A direct lookup, not an
61
+ # ActiveModel validation pass - measured at nine percent of a
62
+ # Button's render when it ran through `valid?`.
63
+ class_attribute :declared_values, instance_accessor: false, default: {}.freeze
64
+
57
65
  class << self
58
66
  # Marks this class (and its descendants) as an implementation
59
67
  # detail - full machinery, no registry entry.
@@ -66,6 +74,86 @@ module Poetry
66
74
  def internal_component!
67
75
  self.internal_component = true
68
76
  end
77
+
78
+ # The vocabulary the runtime values tier checks, computed once per
79
+ # class at first construction (the class body is complete by then):
80
+ # the recorded `style`/`option` declarations plus every inclusion
81
+ # validator with an Array (`validates :side, inclusion: { in: SIDES }`
82
+ # is how options declare a closed vocabulary). Method validators are
83
+ # not vocabulary and stay the component's own (Icon's unknown-name
84
+ # policy).
85
+ #
86
+ # @return [Hash{Symbol => Hash}]
87
+ # @api private
88
+ def runtime_vocabulary
89
+ @runtime_vocabulary ||= begin
90
+ vocabulary = declared_values.dup
91
+ names = attribute_names.map(&:to_sym)
92
+ validators.each do |validator|
93
+ next unless validator.is_a?(ActiveModel::Validations::InclusionValidator)
94
+ next unless (allowed = validator.options[:in]).is_a?(Array)
95
+
96
+ validator.attributes.each do |attribute|
97
+ attribute = attribute.to_sym
98
+ next unless names.include?(attribute)
99
+
100
+ spec = vocabulary[attribute] || { variants: nil, required: false }
101
+ # A style declared open (no variants) stays open: an inherited
102
+ # inclusion validator from a parent's closed declaration never
103
+ # narrows it again.
104
+ next if spec[:open]
105
+
106
+ vocabulary[attribute] = spec.merge(variants: allowed).freeze unless spec[:variants]
107
+ end
108
+ end
109
+ vocabulary.freeze
110
+ end
111
+ end
112
+
113
+ # Records a declared attribute's vocabulary for the runtime values
114
+ # tier ({#guard_declared_values}).
115
+ #
116
+ # @param name [Symbol]
117
+ # @param variants [Array, nil] the closed vocabulary, nil for open values
118
+ # @param required [Boolean]
119
+ # @return [void]
120
+ # @api private
121
+ def record_declared_value(name, variants:, required:, open: false)
122
+ spec = { variants: variants, required: required, open: open }.freeze
123
+ self.declared_values = declared_values.merge(name.to_sym => spec).freeze
124
+ end
125
+
126
+ # Names the view helper that renders this component - for a
127
+ # component the host application (or an engine) defines on the
128
+ # DSL. The poetry-core engine defines the method on Action View at
129
+ # boot and on every reload, and the app's registry carries the name
130
+ # so `poetry check`, llms.txt and the generated skill know the
131
+ # helper. Own-class only: a subclass declares its own or has none.
132
+ # The gems' components do not declare one; their helpers follow the
133
+ # `poetry_<name>` convention.
134
+ #
135
+ # @example
136
+ # class Demo::Badge::Component < Poetry::Core::Component
137
+ # helper :demo_badge
138
+ # end
139
+ # # <%= demo_badge(tone: :loud) { "New" } %>
140
+ # @param name [Symbol, String] a Ruby method name
141
+ # @return [void]
142
+ # @raise [ArgumentError] when the name is not a plain method name
143
+ def helper(name)
144
+ name = name.to_s
145
+ unless name.match?(/\A[a-z_][a-z0-9_]*\z/)
146
+ raise ArgumentError, "helper name #{name.inspect} must be a plain method name (a-z, 0-9, _)"
147
+ end
148
+
149
+ @helper_name = name
150
+ end
151
+
152
+ # The declared helper name, or nil (the gems' components; a
153
+ # subclass that declares none).
154
+ #
155
+ # @return [String, nil]
156
+ attr_reader :helper_name
69
157
  end
70
158
 
71
159
  class << self
@@ -279,10 +367,14 @@ module Poetry
279
367
  end
280
368
 
281
369
  @attributes = self.class._default_attributes.deep_dup
282
- html_attrs = guard_passthrough(attributes.with_indifferent_access.except(*attribute_names))
283
- @html_attributes = Poetry::Core::HTML::Attributes.new(html_attrs)
284
-
285
- assign_attributes attributes.with_indifferent_access.slice(*attribute_names)
370
+ given = attributes.with_indifferent_access
371
+ names = attribute_names
372
+ @html_attributes = Poetry::Core::HTML::Attributes.new(guard_passthrough(given.except(*names)))
373
+ # The root merges classes with the kit's merger (its CSS mode's).
374
+ @html_attributes.classname_merger = classname_merger
375
+
376
+ assign_attributes given.slice(*names)
377
+ guard_declared_values
286
378
  end
287
379
 
288
380
  # The passthrough contract, enforced at the seam. A keyword that is not
@@ -343,12 +435,55 @@ module Poetry
343
435
  self.class.name ? self.class.component_title : "this component"
344
436
  end
345
437
 
438
+ # The runtime values tier, beside the passthrough guard: the declared
439
+ # vocabulary (`style ... variants:`, `required:`) is checked at
440
+ # construction, where every component - gem or app, whatever it does
441
+ # in before_render - passes. Off-list values raise in development and
442
+ # test with the allowed values and a did-you-mean; in production they
443
+ # log and render as before. `poetry check` catches the same mistakes
444
+ # in templates statically; this tier catches the value that arrives
445
+ # from data. A direct lookup over {.declared_values}; a component's
446
+ # custom validators keep their own policy.
447
+ def guard_declared_values
448
+ problems = nil
449
+ self.class.runtime_vocabulary.each do |name, spec|
450
+ value = public_send(name)
451
+ if value.nil? || (value.respond_to?(:empty?) && value.empty?)
452
+ (problems ||= []) << "#{passthrough_owner} requires #{name}:" if spec[:required]
453
+ elsif spec[:variants] && !spec[:variants].include?(value)
454
+ (problems ||= []) << off_list_problem(name, value, spec[:variants])
455
+ end
456
+ end
457
+ return unless problems
458
+
459
+ message = problems.join("; ")
460
+ raise ArgumentError, message if strict_passthrough?
461
+
462
+ Rails.logger&.warn("poetry: #{message}") if defined?(Rails) && Rails.respond_to?(:logger)
463
+ end
464
+
465
+ # The message a developer reads for an off-list value: the declared
466
+ # vocabulary and a did-you-mean.
467
+ def off_list_problem(name, value, allowed)
468
+ "#{passthrough_owner} #{name}: #{value.inspect} is not one of " \
469
+ "#{allowed.map(&:inspect).join(", ")}#{variant_suggestion(value, allowed)}"
470
+ end
471
+
472
+ def variant_suggestion(value, allowed)
473
+ return "" if allowed.empty? || value.nil?
474
+
475
+ require "did_you_mean"
476
+ suggestion = DidYouMean::SpellChecker.new(dictionary: allowed.map(&:to_s)).correct(value.to_s).first
477
+ suggestion ? " (did you mean #{suggestion.to_sym.inspect}?)" : ""
478
+ end
479
+
346
480
  # Raise (development, test) or log (everything else).
347
481
  def strict_passthrough?
348
482
  defined?(Rails) && Rails.respond_to?(:env) && Rails.env.local?
349
483
  end
350
484
 
351
- private :guard_passthrough, :option_suggestion, :passthrough_owner, :strict_passthrough?
485
+ private :guard_passthrough, :option_suggestion, :passthrough_owner, :strict_passthrough?,
486
+ :guard_declared_values, :off_list_problem, :variant_suggestion
352
487
 
353
488
  # Returns all component attributes, ensuring proc defaults are evaluated.
354
489
  #
@@ -421,15 +556,24 @@ module Poetry
421
556
 
422
557
  # Merges multiple class name values into a single string.
423
558
  #
424
- # Uses the configured classname merger (typically Tailwind Merge) to
425
- # intelligently combine CSS class names, handling conflicts and duplicates.
559
+ # Uses the kit's classname merger (Tailwind Merge in :tailwind mode,
560
+ # the BEM merger in :bem mode - {Poetry::Core::CSS::Modes.merger_for})
561
+ # to combine CSS class names, handling conflicts and duplicates.
426
562
  #
427
563
  # @param classnames [Array<String, nil>] class names to merge
428
564
  # @return [String] the merged class names
429
565
  # @example
430
566
  # classnames("text-red-500", "text-blue-500") # => "text-blue-500"
431
567
  def classnames(*classnames)
432
- self.class.config.classname_merger.merge(*classnames)
568
+ classname_merger.merge(*classnames)
569
+ end
570
+
571
+ # The class-name merger this component merges with: the one that
572
+ # matches its CSS mode ({Poetry::Core::CSS::Modes.merger_for}).
573
+ #
574
+ # @return [#merge]
575
+ def classname_merger
576
+ Poetry::Core::CSS::Modes.merger_for(css_mode)
433
577
  end
434
578
 
435
579
  # HTML-safe JSON for embedding in a `<script type="application/json">`
@@ -195,6 +195,7 @@ module Poetry
195
195
  # @param _type [Symbol] the attribute type (unused; kept for signature parity)
196
196
  # @param required [Boolean] whether the attribute is required
197
197
  def add_option_validations(name, _type, required)
198
+ record_declared_value(name, variants: nil, required: required)
198
199
  # Type validation is automatic via ActiveModel::Type
199
200
  validates name, presence: true if required
200
201
  end
@@ -53,6 +53,8 @@ module Poetry
53
53
  STYLE_CLASS_SUFFIX = "::Style"
54
54
 
55
55
  included do
56
+ # The kit-level mode declaration (`css_mode :bem`), inherited.
57
+ class_attribute :declared_css_mode, instance_accessor: false, default: nil
56
58
  class_attribute :registered_styles,
57
59
  instance_writer: true,
58
60
  instance_predicate: false,
@@ -60,6 +62,28 @@ module Poetry
60
62
  end
61
63
 
62
64
  class_methods do
65
+ # The CSS mode this component renders in, declared per kit
66
+ # (inherited by subclasses): `css_mode :bem` on a kit's base
67
+ # class, and every component in it emits the BEM token IR. With
68
+ # no argument, the resolved mode: the declaration, else the mode
69
+ # pinned for the namespace ({Poetry::Core::CSS::Modes} - poetry-ui
70
+ # pins `:tailwind`), else the global
71
+ # `Poetry::Core::Config.current.css_mode`. A `css_mode:` keyword
72
+ # on a `css` call still wins over all of them.
73
+ #
74
+ # @example A kit that brings its own CSS
75
+ # class Acme::Ui::Base < Poetry::Core::Component
76
+ # css_mode :bem
77
+ # end
78
+ # @param mode [Symbol, nil] :tailwind or :bem to declare; nil to read
79
+ # @return [Symbol] the declared or resolved mode
80
+ # @raise [Poetry::Core::Error] for an unknown mode
81
+ def css_mode(mode = nil)
82
+ return self.declared_css_mode = Poetry::Core::CSS::Modes.validate!(mode) unless mode.nil?
83
+
84
+ declared_css_mode || Poetry::Core::CSS::Modes.inherited_for(self) || Poetry::Core::Config.current.css_mode
85
+ end
86
+
63
87
  # Defines a style attribute for the component.
64
88
  #
65
89
  # @param name [Symbol, String] the name of the style attribute
@@ -145,7 +169,12 @@ module Poetry
145
169
  style_class_name = component_module + STYLE_CLASS_SUFFIX
146
170
  style_class_name.constantize
147
171
  rescue NameError => e
148
- log_style_class_not_found(style_class_name, e) if defined?(Rails)
172
+ # Once per class: a component with no dictionary (Icon) is a
173
+ # design, not a line per render in the development log.
174
+ unless @style_class_miss_logged
175
+ @style_class_miss_logged = true
176
+ log_style_class_not_found(style_class_name, e) if defined?(Rails)
177
+ end
149
178
  nil
150
179
  end
151
180
 
@@ -177,10 +206,14 @@ module Poetry
177
206
  # @param variants [Object] the allowed variants
178
207
  # @param required [Boolean] whether the attribute is required
179
208
  def add_style_validations(name, type, variants, required)
209
+ record_declared_value(name, variants: type == :boolean ? nil : variants, required: required,
210
+ open: type != :boolean && variants.nil?)
211
+ # nil is an omitted value, not an off-list one; `required` adds
212
+ # presence for the styles that may not be omitted.
180
213
  if type == :boolean
181
- validates name, inclusion: { in: [true, false] }
214
+ validates name, inclusion: { in: [true, false] }, allow_nil: true
182
215
  elsif variants
183
- validates name, inclusion: { in: variants }
216
+ validates name, inclusion: { in: variants }, allow_nil: true
184
217
  end
185
218
  validates name, presence: true if required
186
219
  end
@@ -274,7 +307,7 @@ module Poetry
274
307
  # @raise [Poetry::Core::Error] for a css_mode other than :tailwind
275
308
  # or :bem
276
309
  def css(element = nil, **options, &)
277
- mode = options.delete(:css_mode) || Poetry::Core::Config.current.css_mode
310
+ mode = options.delete(:css_mode) || css_mode
278
311
 
279
312
  case mode
280
313
  when :tailwind
@@ -288,10 +321,18 @@ module Poetry
288
321
  extra = options.delete(:class)
289
322
  [bem(element, **options), extra].compact.join(" ")
290
323
  else
291
- raise Poetry::Core::Error, "unknown css_mode #{mode.inspect} (expected :tailwind or :bem)"
324
+ Poetry::Core::CSS::Modes.validate!(mode)
292
325
  end
293
326
  end
294
327
 
328
+ # The CSS mode this instance renders in (the class's resolved mode;
329
+ # see the class-level `css_mode`).
330
+ #
331
+ # @return [Symbol] :tailwind or :bem
332
+ def css_mode
333
+ self.class.css_mode
334
+ end
335
+
295
336
  # The component's BEM block name - the stable, framework-agnostic
296
337
  # class contract of the token IR ("poetry/core/dot" -> "poetry-core-dot").
297
338
  #
@@ -27,7 +27,38 @@ module Poetry
27
27
  #
28
28
  # @return [CSS::Resolver]
29
29
  def resolver
30
- @resolver ||= superclass.respond_to?(:resolver) ? superclass.resolver.dup : CSS::Resolver.new
30
+ @resolver ||= begin
31
+ dictionary = superclass.respond_to?(:resolver) ? superclass.resolver.dup : CSS::Resolver.new
32
+ dictionary.owner = self
33
+ dictionary
34
+ end
35
+ end
36
+
37
+ # The class merger for this dictionary's mode: the sidecar
38
+ # component's resolved `css_mode` (its own declaration, its kit's
39
+ # pin, or the global), else this class's own namespace pin, else the
40
+ # global mode - so `Acme::Pill::Style` under a `:bem` pin merges like
41
+ # BEM while poetry-ui's dictionaries merge like Tailwind whatever the
42
+ # host set globally.
43
+ #
44
+ # @return [Object] a class-name merger
45
+ def merger
46
+ component = sidecar_component
47
+ mode = if component.respond_to?(:css_mode)
48
+ component.css_mode
49
+ else
50
+ CSS::Modes.inherited_for(self) || Poetry::Core::Config.current.css_mode
51
+ end
52
+ CSS::Modes.merger_for(mode)
53
+ end
54
+
55
+ # The `X::Component` beside this `X::Style`, when it exists.
56
+ def sidecar_component
57
+ return nil unless name&.end_with?("::Style")
58
+
59
+ Object.const_get("#{name.delete_suffix("::Style")}::Component")
60
+ rescue NameError
61
+ nil
31
62
  end
32
63
 
33
64
  # -- The dictionary DSL (delegates to the resolver) --------------------
@@ -14,7 +14,9 @@ components:
14
14
  description: The element to render ('div', 'section', 'span', ...); void elements
15
15
  (br, hr, img, ...) self-close and take no content block.
16
16
  slots: []
17
+ helper: poetry_box
17
18
  identity: false
18
19
  parts:
19
20
  - name: box
20
21
  description: The rendered element itself - the whole component is one part
22
+ internal: true
@@ -68,6 +68,19 @@ module Poetry
68
68
 
69
69
  private
70
70
 
71
+ # The gem convention plus every helper the catalog knows by a name
72
+ # of its own (an app component's declared helper), so the
73
+ # heuristics cover app components too.
74
+ def helper_pattern
75
+ @helper_pattern ||= begin
76
+ declared = @catalog.helper_names.reject { |name| name.start_with?("poetry_") }
77
+ # A bare call: not after a dot or a colon (`u.badge`, `Foo::badge`).
78
+ Regexp.union(/(?<![\w.:])(poetry_[a-z0-9_]+)\b/, *declared.map do |name|
79
+ /(?<![\w.:])(#{Regexp.escape(name)})\b/
80
+ end)
81
+ end
82
+ end
83
+
71
84
  def frame_for(code)
72
85
  return :cache if code.match?(CACHE_OPENER)
73
86
  return :loop if code.match?(LOOP_OPENER)
@@ -76,7 +89,7 @@ module Poetry
76
89
  end
77
90
 
78
91
  def flag_helpers(code, line, stack, findings)
79
- code.scan(/\b(poetry_[a-z0-9_]+)\b/) do |(helper)|
92
+ code.scan(helper_pattern) do |(helper)|
80
93
  next unless @catalog.helper?(helper)
81
94
  next if @catalog.identity_free?(helper)
82
95
  next if code.match?(IDENTITY_ARG)