poetry-core 0.1.7 → 0.1.9
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 +4 -4
- data/CHANGELOG.md +26 -0
- data/app/components/poetry/core/component.rb +61 -4
- data/app/components/poetry/core/concerns/options.rb +14 -1
- data/lib/active_model/type/html_safe_string.rb +34 -0
- data/lib/poetry/core/check/compile.rb +137 -0
- data/lib/poetry/core/check/renderable_readers.rb +116 -0
- data/lib/poetry/core/check/stable_identity.rb +8 -3
- data/lib/poetry/core/check/tag_helpers.rb +156 -0
- data/lib/poetry/core/check.rb +53 -17
- data/lib/poetry/core/engine.rb +16 -7
- data/lib/poetry/core/host_helpers.rb +38 -0
- data/lib/poetry/core/slot_markers.rb +77 -0
- data/lib/poetry/core/tag_helper.rb +43 -1
- data/lib/poetry/core/template_compile.rb +112 -20
- data/lib/poetry/core/version.rb +1 -1
- data/lib/poetry/core.rb +1 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1831a4d45731d76d83cfdbb9a65b20433a32bf7c57a15297f7838e04d0a55c21
|
|
4
|
+
data.tar.gz: a65f3175560897a70c6f2f8e29d3373bf3c5ef02f7baae20c35fcc3ddb4b3b6f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2f77187b648438d1e648956744425e82efbeb28f4b8efe0aa19b04ec647db0420bc6320405f01ccb938d6594856974c26702a2572c972a5a71134c16afa67e85
|
|
7
|
+
data.tar.gz: 59f36c4032dc99a6643486ae88f1cacfe71cf7f1ab94f046ee22654276feba9baa4a95244bfa8a011edd2731d269a8dee2408b458e00d00c52367d38076ee647
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.9] - 2026-09-30
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `element_tag(name, ..., **attributes)`, on every component and in every view: the helper a template builds its elements through, and where a component's template spends what `root_attributes` and `element_attributes` build. It takes what `content_tag` takes and renders the same bytes, and it renders a void element the way `tag.input` does. A host that compiles its templates with Herb's slots has Action View's own tag helpers resolved into markup ahead of the render, and a splat or a tag name held in a variable does not survive that: the element comes out without its attributes, or the template stops compiling. A helper Herb does not know stays a call. Components written on the DSL in a host should build their elements the same way.
|
|
8
|
+
- `poetry check` holds a component's template (an ERB file under `app/components/`) to the element rule, `element-tag`, as a warning: a tag helper handed a splat or a variable for its attributes, `content_tag` given a tag name that is not a literal, and `tag.attributes` handed a splat each name the `element_tag` call to write instead. A view is left alone: handing a tag helper a splat is valid Rails, and whether the host compiles with slots is not something a lint of the source can know.
|
|
9
|
+
- `poetry check` compiles a component's HTML template under Herb, in the posture `bin/rails herb:check` holds an app's views to: the security validator, with the compiled Ruby checked for syntax. Rails renders HTML templates through Herb under its 8.2 defaults and its task walks the view paths, so a template under `app/components/` is outside its reach. Rails checks views, Poetry checks components. A template Herb refuses is a `herb-compile` finding that carries the engine's reason and its line: an error when the app renders through Herb, a warning until then. The runner compiles when it is handed the tier (`Check::Runner.new(catalog, compile:)`), and `Poetry::Core::TemplateCompile` takes `posture: :rails` for it beside the strict posture the gems' own gate runs.
|
|
10
|
+
- `poetry check` holds a component class to the renderable rule, `renderable-reader`: an `option`, a `style` or a slot named `format` defines the reader Rails asks a component for the format of its template, and it answers with the declared value, so a render through the renderer raises `Invalid formats`. From Rails 8.2 every render in a view goes through the renderer, so the host's check reports it as an error there and as a warning before. A class that defines `format` itself, with `def` or `define_method`, is left alone: it keeps the keyword for its callers and reads the value under another name, which is what NumberField does.
|
|
11
|
+
- `Poetry::Core::SlotMarkers.strip(html)` takes off what a slot compile writes around a template's own markup: the region, slot and branch comments, the `data-herb-slot` attribute and the branches parked in a `<template>`. What is left is the markup the template wrote, which is what a test in a host that compiles with slots means to compare.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- The helpers of an app's own components (`helper :name`) are defined before the first view renders, not while the application is still initializing. The engine used to load every `app/components` directory from `to_prepare`, and a component is a view, so Action View loaded before initialization completed: Rails logs that in a boot that does not eager load, and raises on it when `config.action_on_early_load_hook` is `:raise`. A host that eager loads now syncs once its boot is done, with the classes already loaded; every other host syncs on its first view context, at render time; a reload syncs as before. `Poetry::Core::HostHelpers.sync_now!` is the sync, `ready?` says whether the first one has happened, and `HostHelpers::FirstView` is what Action View's base class gets for it.
|
|
16
|
+
- The template compile gate (`rake herb:compile`, `Poetry::Core::TemplateCompile`) runs on Herb 0.11. It requires the engine itself, since `require "herb"` no longer loads it. It hands the engine every validator Herb ships, each one fatal, since from 0.11 the engine validates nothing it is not handed, and has the compiled Ruby checked for syntax. It lists parse and compile errors, which Herb now raises as syntax errors, where they used to pass the rescue and abort the run. A Herb older than 0.11.0 raises a setup error that names the version found.
|
|
17
|
+
|
|
18
|
+
## [0.1.8] - 2026-09-22
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `ActiveModel::Type::HtmlSafeString`, the type behind every `option :name, :string` declaration: an html-safe value (a hint built with `link_to`, a translated `_html` key) stays html-safe where ActiveModel's String type copied it into a plain String, so a Field's `hint:` or `error:` renders the markup it was given; a plain String still escapes at render. The type is substituted inside the option DSL only, never registered, and reports `:string`, so the registry reads every declaration unchanged.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- A content block that returns a scalar (a number, a symbol, a boolean) while writing nothing to the buffer captures as nothing, so `poetry_badge { count }` rendered an empty badge without a word. A component that declares `requires_content` now raises in development and test naming the value and the fix (call `to_s` on it, or write it with `<%= %>`) and logs in production; every other component logs. A block that wrote to the buffer, returned a String or nil, or returned a collection (an empty loop's `each`) is untouched.
|
|
27
|
+
- `poetry check`'s stable-identity rules skip a pathless helper whose registry `helpers` entry declares `identity: false` (a table part helper stamps no poetry-minted id, so `key:` had nothing to stabilize and the warning was noise), and read a double-splat of an id-bearing hash (`**field.control_attributes`, `**attrs`) as identity: the id arrives through the splat.
|
|
28
|
+
|
|
3
29
|
## [0.1.7] - 2026-09-20
|
|
4
30
|
|
|
5
31
|
### Changed
|
|
@@ -39,6 +39,7 @@ module Poetry
|
|
|
39
39
|
include ActiveModel::AttributeAssignment
|
|
40
40
|
include ActiveModel::Validations
|
|
41
41
|
include Poetry::Core::Contrib::WrappedHelper
|
|
42
|
+
include Poetry::Core::TagHelper
|
|
42
43
|
include Poetry::Core::Concerns::Styles
|
|
43
44
|
include Poetry::Core::Concerns::Options
|
|
44
45
|
include Poetry::Core::Concerns::Stimulus
|
|
@@ -296,12 +297,12 @@ module Poetry
|
|
|
296
297
|
# super("role" => "status", "data-variant" => variant)
|
|
297
298
|
# end
|
|
298
299
|
# @example The template
|
|
299
|
-
# <%=
|
|
300
|
+
# <%= element_tag(:div, **root_attributes) do %>
|
|
300
301
|
#
|
|
301
302
|
# @param extra [Hash] the component's own root attributes; a
|
|
302
303
|
# `"data-slot"` here replaces the default
|
|
303
304
|
# @return [Hash] the root's attributes, flat (`data-x`, `aria-x`,
|
|
304
|
-
# booleans as the attribute name), for
|
|
305
|
+
# booleans as the attribute name), for {Poetry::Core::TagHelper#element_tag}
|
|
305
306
|
def root_attributes(extra = {})
|
|
306
307
|
own = { "data-slot" => root_slot }.merge(extra).merge(component_data_attributes)
|
|
307
308
|
wired = self.class.stimulus_elements.key?(:root) ? :root : nil
|
|
@@ -335,7 +336,7 @@ module Poetry
|
|
|
335
336
|
# attrs["hidden"] = true unless open
|
|
336
337
|
# element_attributes(:content, attrs)
|
|
337
338
|
# end
|
|
338
|
-
# # <%=
|
|
339
|
+
# # <%= element_tag(:div, **content_attributes) do %>
|
|
339
340
|
#
|
|
340
341
|
# @param part [Symbol, String, nil] the part; nil (or a Hash in its
|
|
341
342
|
# place) for markup with no part of its own
|
|
@@ -343,7 +344,7 @@ module Poetry
|
|
|
343
344
|
# @param stimulus [Symbol, String, false, nil] the `use_stimulus`
|
|
344
345
|
# element whose wiring joins the markup: nil takes the part's own when
|
|
345
346
|
# one is declared, false takes none
|
|
346
|
-
# @return [Hash] the element's attributes, flat, for
|
|
347
|
+
# @return [Hash] the element's attributes, flat, for {Poetry::Core::TagHelper#element_tag}
|
|
347
348
|
def element_attributes(part = nil, attrs = {}, stimulus: nil)
|
|
348
349
|
if part.is_a?(Hash)
|
|
349
350
|
attrs = part
|
|
@@ -382,7 +383,63 @@ module Poetry
|
|
|
382
383
|
"(#{self.class.required_content})"
|
|
383
384
|
end
|
|
384
385
|
|
|
386
|
+
# ViewComponent's render_in, with the content block wrapped once so
|
|
387
|
+
# #content can read what the block RETURNED - capture keeps only what
|
|
388
|
+
# the block wrote to the buffer, and a scalar return vanishes there.
|
|
389
|
+
#
|
|
390
|
+
# @param view_context [ActionView::Base] the rendering view
|
|
391
|
+
# @return [ActiveSupport::SafeBuffer] the rendered component
|
|
392
|
+
def render_in(view_context, **)
|
|
393
|
+
@content_guarded = false
|
|
394
|
+
remove_instance_variable(:@content_return) if defined?(@content_return)
|
|
395
|
+
return super unless block_given?
|
|
396
|
+
|
|
397
|
+
recorded = proc { |*args| @content_return = yield(*args) }
|
|
398
|
+
super(view_context, **, &recorded)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# The captured content block, guarded once per render: a block that
|
|
402
|
+
# returned a scalar (a number, a symbol, a boolean) while writing
|
|
403
|
+
# nothing to the buffer captures as nothing at all - `poetry_badge {
|
|
404
|
+
# count }` renders an empty badge - so a requires_content component
|
|
405
|
+
# raises in development and test naming the value and the fix, and
|
|
406
|
+
# logs everywhere else; a component without the declaration logs. A
|
|
407
|
+
# block that wrote to the buffer, returned a String, returned nil,
|
|
408
|
+
# returned a collection (an empty loop's each) or composed slots
|
|
409
|
+
# (its last call a with_* setter, which returns the slot or the
|
|
410
|
+
# component) is fine.
|
|
411
|
+
#
|
|
412
|
+
# @return [ActiveSupport::SafeBuffer, nil] the captured content
|
|
413
|
+
def content
|
|
414
|
+
captured = super
|
|
415
|
+
guard_content_return(captured) unless @content_guarded
|
|
416
|
+
captured
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# The content-return guard behind #content.
|
|
420
|
+
#
|
|
421
|
+
# @param captured [ActiveSupport::SafeBuffer, nil] what capture kept
|
|
422
|
+
# @return [void]
|
|
423
|
+
# @raise [ArgumentError] a scalar return on a requires_content
|
|
424
|
+
# component, in development and test
|
|
425
|
+
def guard_content_return(captured)
|
|
426
|
+
@content_guarded = true
|
|
427
|
+
return unless defined?(@content_return)
|
|
428
|
+
|
|
429
|
+
value = @content_return
|
|
430
|
+
return if value.nil? || value.is_a?(::String) || value.respond_to?(:each) || captured.present?
|
|
431
|
+
return if value.is_a?(ViewComponent::Base) || value.is_a?(ViewComponent::Slot)
|
|
432
|
+
|
|
433
|
+
message = "#{passthrough_owner} content block returned #{value.inspect.truncate(60)} (#{value.class}), " \
|
|
434
|
+
"which captures as nothing - return a String (call to_s on it) or write it with <%= %>"
|
|
435
|
+
raise ArgumentError, message if strict_passthrough? && self.class.required_content
|
|
436
|
+
|
|
437
|
+
Rails.logger&.warn("poetry: #{message}") if defined?(Rails) && Rails.respond_to?(:logger)
|
|
438
|
+
end
|
|
439
|
+
private :guard_content_return
|
|
440
|
+
|
|
385
441
|
# Indicates whether this component instance is persisted.
|
|
442
|
+
|
|
386
443
|
# Always returns false as components are not persisted entities.
|
|
387
444
|
#
|
|
388
445
|
# @return [Boolean] always returns false
|
|
@@ -90,8 +90,9 @@ module Poetry
|
|
|
90
90
|
required, default_value = extract_declared_defaults(options)
|
|
91
91
|
register_option_format(name, options.delete(:format))
|
|
92
92
|
|
|
93
|
-
attribute(name, type, **options)
|
|
93
|
+
attribute(name, option_cast_type(type), **options)
|
|
94
94
|
add_option_validations(name, type, required)
|
|
95
|
+
|
|
95
96
|
setup_declared_tracking(:option, name, default_value)
|
|
96
97
|
define_type_getter(name, type)
|
|
97
98
|
end
|
|
@@ -200,6 +201,18 @@ module Poetry
|
|
|
200
201
|
validates name, presence: true if required
|
|
201
202
|
end
|
|
202
203
|
|
|
204
|
+
# The ActiveModel type an option declaration casts with. A :string
|
|
205
|
+
# option casts with ActiveModel::Type::HtmlSafeString, which keeps
|
|
206
|
+
# an html-safe value (a link, a translated `_html` key) html-safe
|
|
207
|
+
# where the String type copied it into a plain String; every other
|
|
208
|
+
# type is looked up by name as ActiveModel does.
|
|
209
|
+
#
|
|
210
|
+
# @param type [Symbol] the declared type
|
|
211
|
+
# @return [Symbol, ActiveModel::Type::Value] what `attribute` receives
|
|
212
|
+
def option_cast_type(type)
|
|
213
|
+
type == :string ? ActiveModel::Type::HtmlSafeString.new : type
|
|
214
|
+
end
|
|
215
|
+
|
|
203
216
|
# Defines a singleton method to access the type for this attribute.
|
|
204
217
|
#
|
|
205
218
|
# @param name [Symbol] the attribute name
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_model/type"
|
|
4
|
+
|
|
5
|
+
module ActiveModel
|
|
6
|
+
module Type
|
|
7
|
+
# The string type behind every `option :name, :string` declaration:
|
|
8
|
+
# ActiveModel's String type copies each value into a fresh String,
|
|
9
|
+
# which drops the html_safe mark a SafeBuffer carries, so a caller's
|
|
10
|
+
# `hint: link_to(...)` or `t(".hint_html")` arrived escaped. This
|
|
11
|
+
# type keeps an html-safe value as it is and casts everything else
|
|
12
|
+
# exactly as the String type does; a plain String still escapes at
|
|
13
|
+
# render, because nothing here marks anything safe.
|
|
14
|
+
#
|
|
15
|
+
# Substituted inside the option DSL only - never registered, so
|
|
16
|
+
# `attribute :x, :string` in a host model keeps Rails' own type - and
|
|
17
|
+
# it reports :string, so the registry and every introspection surface
|
|
18
|
+
# read the declaration unchanged.
|
|
19
|
+
class HtmlSafeString < ActiveModel::Type::String
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# An html-safe String passes through untouched; everything else casts
|
|
23
|
+
# as a String does.
|
|
24
|
+
#
|
|
25
|
+
# @param value [Object] the raw option value
|
|
26
|
+
# @return [::String]
|
|
27
|
+
def cast_value(value)
|
|
28
|
+
return value if value.is_a?(::String) && value.html_safe?
|
|
29
|
+
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Core
|
|
5
|
+
module Check
|
|
6
|
+
# The compile tier for component templates: each one is compiled
|
|
7
|
+
# under Herb the way `bin/rails herb:check` compiles an app's views.
|
|
8
|
+
#
|
|
9
|
+
# Rails renders HTML templates through Herb under its 8.2 framework
|
|
10
|
+
# defaults, and its check task tells an app which views will not
|
|
11
|
+
# compile. That task walks the view paths. A template that sits
|
|
12
|
+
# beside its component lives outside them, so nothing reaches it
|
|
13
|
+
# before the page that renders the component does. Rails checks
|
|
14
|
+
# views, this checks components: the app's own, and the ones the gems
|
|
15
|
+
# in the boot ship.
|
|
16
|
+
#
|
|
17
|
+
# The severity follows the app. One that renders through Herb has the
|
|
18
|
+
# template fail where it is used, which is an error. One that renders
|
|
19
|
+
# through Erubi renders it as it always has, so the finding is a
|
|
20
|
+
# WARNING about the day the app adopts the defaults.
|
|
21
|
+
#
|
|
22
|
+
# @api private
|
|
23
|
+
class Compile
|
|
24
|
+
# The rule the findings carry.
|
|
25
|
+
RULE = "herb-compile"
|
|
26
|
+
|
|
27
|
+
# The HTML templates of the components under a root, variants
|
|
28
|
+
# included. Rails compiles no other format through Herb.
|
|
29
|
+
GLOB = "app/components/**/*.html{,+*}.erb"
|
|
30
|
+
|
|
31
|
+
# A component's HTML template by convention: a file under
|
|
32
|
+
# `app/components/` in the HTML format, with or without a variant.
|
|
33
|
+
HTML_TEMPLATE = %r{(?:\A|/)app/components/.+\.html(?:\+[\w-]+)?\.erb\z}
|
|
34
|
+
|
|
35
|
+
# The path, line and column the engine opens a message with.
|
|
36
|
+
LOCATION = /\A:\d+:\d+(?::| -) /
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# Whether a path is a component's HTML template by convention.
|
|
40
|
+
#
|
|
41
|
+
# @param path [String]
|
|
42
|
+
# @return [Boolean]
|
|
43
|
+
def html_template?(path)
|
|
44
|
+
HTML_TEMPLATE.match?(path.to_s)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Why the tier cannot run, or nil when it can: the herb gem is
|
|
48
|
+
# missing, or older than the engine the gate is written for.
|
|
49
|
+
#
|
|
50
|
+
# @return [String, nil]
|
|
51
|
+
def unavailable
|
|
52
|
+
TemplateCompile.compile("")
|
|
53
|
+
nil
|
|
54
|
+
rescue Poetry::Core::Error => e
|
|
55
|
+
e.message
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Whether the booted app compiles its HTML templates with Herb:
|
|
59
|
+
# the handler registered for ERB hands them to the engine, or
|
|
60
|
+
# Rails' own implementation switch names it.
|
|
61
|
+
#
|
|
62
|
+
# @return [Boolean]
|
|
63
|
+
def rendering?
|
|
64
|
+
return false unless defined?(::Herb::Engine) && defined?(::ActionView::Template)
|
|
65
|
+
|
|
66
|
+
handler = ::ActionView::Template.handler_for_extension(:erb)
|
|
67
|
+
owner = handler.is_a?(Module) ? handler : handler.class
|
|
68
|
+
return true if defined?(::ReActionView::Template::Handlers::ERB) &&
|
|
69
|
+
owner <= ::ReActionView::Template::Handlers::ERB
|
|
70
|
+
|
|
71
|
+
implementation = owner.erb_implementation if owner.respond_to?(:erb_implementation)
|
|
72
|
+
implementation.is_a?(Class) && implementation <= ::Herb::Engine
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A compile tier for an app.
|
|
77
|
+
#
|
|
78
|
+
# @param rendering [Boolean] whether the app renders its HTML
|
|
79
|
+
# templates through Herb, which makes a finding an error
|
|
80
|
+
def initialize(rendering: false)
|
|
81
|
+
@rendering = rendering
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The finding for one template, when the engine refuses it.
|
|
85
|
+
#
|
|
86
|
+
# @param source [String] the template
|
|
87
|
+
# @param filename [String] its path, which the engine's message opens with
|
|
88
|
+
# @param shipped [Boolean] whether the template ships in a gem
|
|
89
|
+
# @return [Array<Finding>]
|
|
90
|
+
def lint(source, filename:, shipped: false)
|
|
91
|
+
failure = TemplateCompile.failure(source, filename: filename.to_s, posture: :rails)
|
|
92
|
+
return [] unless failure
|
|
93
|
+
|
|
94
|
+
[Finding.new(rule: RULE, severity: @rendering ? :error : :warning, line: failure.line,
|
|
95
|
+
message: message(failure, filename.to_s, shipped))]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The findings for the component templates the roots ship, each
|
|
99
|
+
# stamped with its file. A template that cannot be read is skipped:
|
|
100
|
+
# the render that needs it reports that better than a lint can.
|
|
101
|
+
#
|
|
102
|
+
# @param roots [Array<String, Pathname>] gem roots
|
|
103
|
+
# @return [Array<Finding>]
|
|
104
|
+
def scan(roots)
|
|
105
|
+
roots.flat_map do |root|
|
|
106
|
+
Dir.glob(GLOB, base: root.to_s).sort.flat_map do |relative|
|
|
107
|
+
path = File.join(root.to_s, relative)
|
|
108
|
+
lint(File.read(path, encoding: "UTF-8"), filename: path, shipped: true)
|
|
109
|
+
.each { |finding| finding.file = path }
|
|
110
|
+
rescue SystemCallError, IOError, ArgumentError, Encoding::CompatibilityError
|
|
111
|
+
[]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# The finding's sentence: the compiler, the engine's reason
|
|
119
|
+
# without the path it opens with, and what fixes a shipped template.
|
|
120
|
+
def message(failure, filename, shipped)
|
|
121
|
+
compiler = @rendering ? "this app renders with" : "Rails 8.2 renders with by default"
|
|
122
|
+
text = "does not compile under Herb, the compiler #{compiler}: #{reason(failure, filename)}"
|
|
123
|
+
return text unless shipped
|
|
124
|
+
|
|
125
|
+
"#{text} - the template ships in a gem: update the gem, or hold herb at the version it was " \
|
|
126
|
+
"checked against (the bundle resolves herb #{Herb::VERSION})"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The engine's message on one line, without its location prefix.
|
|
130
|
+
def reason(failure, filename)
|
|
131
|
+
first = failure.message.to_s.each_line.map(&:strip).reject(&:empty?).first.to_s
|
|
132
|
+
first.delete_prefix(filename).sub(LOCATION, "")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Core
|
|
5
|
+
module Check
|
|
6
|
+
# The renderable rule for a component class: no declaration defines
|
|
7
|
+
# the reader Rails asks a component for.
|
|
8
|
+
#
|
|
9
|
+
# Rails reads `format` off what it renders and takes the answer for
|
|
10
|
+
# the format of the template. An `option :format`, a `style :format`
|
|
11
|
+
# or a slot named `format` defines that reader, and it answers with
|
|
12
|
+
# the declared value. A render through the renderer then raises
|
|
13
|
+
# `Invalid formats`. A render from a controller has always gone
|
|
14
|
+
# through it; from Rails 8.2 a render in a view goes through it too,
|
|
15
|
+
# which is every helper call on the page.
|
|
16
|
+
#
|
|
17
|
+
# A class that answers Rails itself is left alone: one that defines
|
|
18
|
+
# `format` in its own body, with `def` or `define_method`, keeps the
|
|
19
|
+
# keyword for its callers and reads the value under another name.
|
|
20
|
+
#
|
|
21
|
+
# @api private
|
|
22
|
+
class RenderableReaders
|
|
23
|
+
# The rule the findings carry.
|
|
24
|
+
RULE = "renderable-reader"
|
|
25
|
+
|
|
26
|
+
# The name Rails reads off a renderable.
|
|
27
|
+
READER = "format"
|
|
28
|
+
|
|
29
|
+
# The declarations that define a reader named after the declaration.
|
|
30
|
+
DECLARATIONS = %w[option style renders_one renders_many].freeze
|
|
31
|
+
|
|
32
|
+
# A rule over a Ruby file; it reads the source alone and needs no
|
|
33
|
+
# catalog.
|
|
34
|
+
#
|
|
35
|
+
# @param severity [Symbol] :warning, or :error where the host's
|
|
36
|
+
# Rails renders a view through the renderer
|
|
37
|
+
def initialize(severity: :warning)
|
|
38
|
+
@severity = severity
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The findings for one Ruby source.
|
|
42
|
+
#
|
|
43
|
+
# @param source [String] the Ruby source
|
|
44
|
+
# @return [Array<Finding>]
|
|
45
|
+
def lint(source)
|
|
46
|
+
require "prism"
|
|
47
|
+
|
|
48
|
+
result = Prism.parse(source)
|
|
49
|
+
return [] unless result.success?
|
|
50
|
+
|
|
51
|
+
findings = []
|
|
52
|
+
walk(result.value, findings)
|
|
53
|
+
findings
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Judges every class body on the way down.
|
|
59
|
+
def walk(node, findings)
|
|
60
|
+
findings.concat(class_findings(node)) if node.is_a?(Prism::ClassNode)
|
|
61
|
+
node.compact_child_nodes.each { |child| walk(child, findings) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The declarations in one class body that define the reader, unless
|
|
65
|
+
# the body defines it itself.
|
|
66
|
+
def class_findings(klass)
|
|
67
|
+
statements = klass.body.is_a?(Prism::StatementsNode) ? klass.body.body : []
|
|
68
|
+
return [] if statements.any? { |statement| answers_rails?(statement) }
|
|
69
|
+
|
|
70
|
+
statements.filter_map do |statement|
|
|
71
|
+
next unless (name = declaration(statement))
|
|
72
|
+
|
|
73
|
+
Finding.new(rule: RULE, severity: @severity, line: statement.location.start_line,
|
|
74
|
+
message: message(name))
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A `def format` or `define_method(:format)` in the class body.
|
|
79
|
+
def answers_rails?(statement)
|
|
80
|
+
return statement.name.to_s == READER if statement.is_a?(Prism::DefNode)
|
|
81
|
+
|
|
82
|
+
call?(statement, "define_method") && literal_name(statement.arguments&.arguments&.first) == READER
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The declaration keyword when the statement declares the reader.
|
|
86
|
+
def declaration(statement)
|
|
87
|
+
return unless statement.is_a?(Prism::CallNode) && statement.receiver.nil?
|
|
88
|
+
|
|
89
|
+
name = statement.name.to_s
|
|
90
|
+
return unless DECLARATIONS.include?(name)
|
|
91
|
+
return unless literal_name(statement.arguments&.arguments&.first) == READER
|
|
92
|
+
|
|
93
|
+
name
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A receiverless call by that name.
|
|
97
|
+
def call?(node, name)
|
|
98
|
+
node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name.to_s == name
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A symbol or string literal's text, or nil.
|
|
102
|
+
def literal_name(node)
|
|
103
|
+
node.unescaped if node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The finding's sentence.
|
|
107
|
+
def message(name)
|
|
108
|
+
"#{name} :#{READER} defines the reader Rails asks a component for the format of its template, and it " \
|
|
109
|
+
"answers with the declared value: a render through the renderer raises `Invalid formats`, and from " \
|
|
110
|
+
"Rails 8.2 every render in a view goes through it - keep the keyword and read the value under another " \
|
|
111
|
+
"name, with `define_method(:#{READER}) { nil }` beside the declaration, or rename it"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -24,8 +24,12 @@ module Poetry
|
|
|
24
24
|
#
|
|
25
25
|
# A component whose registry entry declares "identity" => false
|
|
26
26
|
# renders no poetry-minted id - key:/id: would have nothing to
|
|
27
|
-
# stabilize - so both rules skip it
|
|
28
|
-
#
|
|
27
|
+
# stabilize - so both rules skip it (a pathless helper declares the
|
|
28
|
+
# same in the registry helpers section). The registry derivation
|
|
29
|
+
# owns that fact per gem; legacy entries without the key keep
|
|
30
|
+
# warning. A double-splat of an id-bearing hash (`**field.control_attributes`,
|
|
31
|
+
# `**attrs`) counts as identity too: the id arrives through the splat,
|
|
32
|
+
# and the scan reads the name, not the hash.
|
|
29
33
|
#
|
|
30
34
|
# @api private
|
|
31
35
|
class StableIdentity
|
|
@@ -36,7 +40,8 @@ module Poetry
|
|
|
36
40
|
CACHE_OPENER = /\A\s*cache[\s(]/
|
|
37
41
|
LOOP_OPENER =
|
|
38
42
|
/\.(?:each|each_with_index|each_with_object|each_slice|map|collect|flat_map)\b.*\bdo\b|\Afor\s.+\sin\s/
|
|
39
|
-
IDENTITY_ARG =
|
|
43
|
+
IDENTITY_ARG =
|
|
44
|
+
/(?:\bkey:|\bid:|["']key["']\s*=>|["']id["']\s*=>|\*\*[\w.@]*(?:control_attributes|attributes|attrs)\b)/
|
|
40
45
|
|
|
41
46
|
# A stable-identity linter over the catalog.
|
|
42
47
|
def initialize(catalog)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Core
|
|
5
|
+
module Check
|
|
6
|
+
# The element rule for component templates: an element is built
|
|
7
|
+
# through `element_tag`, and `tag.attributes` takes its hash as an
|
|
8
|
+
# argument.
|
|
9
|
+
#
|
|
10
|
+
# A host may compile its templates with Herb's slots. That compile
|
|
11
|
+
# resolves Action View's own tag helpers into markup ahead of the
|
|
12
|
+
# render, and three shapes do not survive it: attributes handed over
|
|
13
|
+
# in a splat or in a variable are dropped from the element, and a tag
|
|
14
|
+
# name that is not a literal leaves Ruby that does not parse. Nothing
|
|
15
|
+
# reports the first, and the second fails the page. `element_tag` is
|
|
16
|
+
# a helper Herb does not resolve, so the element is built at render
|
|
17
|
+
# time from what the component handed it.
|
|
18
|
+
#
|
|
19
|
+
# WARNING severity, component templates only: a view that hands a tag
|
|
20
|
+
# helper a splat is valid Rails, and whether its host compiles with
|
|
21
|
+
# slots is not something a lint of the source can know.
|
|
22
|
+
#
|
|
23
|
+
# `strict:` is the rule poetry's own templates are held to: no call to
|
|
24
|
+
# `content_tag` or `tag.<name>` at all, whatever its arguments.
|
|
25
|
+
#
|
|
26
|
+
# @api private
|
|
27
|
+
class TagHelpers
|
|
28
|
+
# An ERB tag with code in it: not a comment, not an escaped tag.
|
|
29
|
+
ERB_TAG = /<%(?![#%])(?:==|=|-)?(.*?)-?%>/m
|
|
30
|
+
|
|
31
|
+
# A tag-helper linter; it reads the source alone and needs no catalog.
|
|
32
|
+
#
|
|
33
|
+
# @param strict [Boolean] flag every call to an Action View tag
|
|
34
|
+
# helper, not only the shapes a slot compile gets wrong
|
|
35
|
+
def initialize(strict: false)
|
|
36
|
+
@strict = strict
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The findings for one ERB source.
|
|
40
|
+
#
|
|
41
|
+
# @param source [String] the template
|
|
42
|
+
# @return [Array<Finding>]
|
|
43
|
+
def lint(source)
|
|
44
|
+
require "prism"
|
|
45
|
+
|
|
46
|
+
source.to_enum(:scan, ERB_TAG).flat_map do
|
|
47
|
+
match = Regexp.last_match
|
|
48
|
+
line = source[0...match.begin(1)].count("\n") + 1
|
|
49
|
+
calls_in(Prism.parse(match[1]).value).filter_map { |call| finding_for(call, line) }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Every call to `content_tag`, `tag.<name>` or `tag.attributes` under a node.
|
|
56
|
+
def calls_in(node, into = [])
|
|
57
|
+
return into unless node
|
|
58
|
+
|
|
59
|
+
into << node if node.is_a?(Prism::CallNode) && tag_helper?(node)
|
|
60
|
+
node.compact_child_nodes.each { |child| calls_in(child, into) } if node.respond_to?(:compact_child_nodes)
|
|
61
|
+
into
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Whether a call is `content_tag(...)` or a method on the bare `tag` builder.
|
|
65
|
+
def tag_helper?(call)
|
|
66
|
+
return true if call.name == :content_tag && call.receiver.nil?
|
|
67
|
+
|
|
68
|
+
builder = call.receiver
|
|
69
|
+
builder.is_a?(Prism::CallNode) && builder.name == :tag && builder.receiver.nil? && builder.arguments.nil?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The finding for one call, or nil when the call is one a slot compile reads right.
|
|
73
|
+
def finding_for(call, base_line)
|
|
74
|
+
line = base_line + call.location.start_line - 1
|
|
75
|
+
written = written(call)
|
|
76
|
+
return attributes_finding(call, written, line) if call.name == :attributes && call.receiver
|
|
77
|
+
|
|
78
|
+
problem = problem_with(call)
|
|
79
|
+
return unless problem || @strict
|
|
80
|
+
|
|
81
|
+
Finding.new(rule: "element-tag", severity: :warning, line: line,
|
|
82
|
+
message: "#{written} #{problem || "is an Action View tag helper"} - " \
|
|
83
|
+
"build the element with #{replacement(call)}")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The finding for `tag.attributes` handed a splat; its hash as an argument is the form to keep.
|
|
87
|
+
def attributes_finding(call, written, line)
|
|
88
|
+
return unless splat?(call)
|
|
89
|
+
|
|
90
|
+
Finding.new(rule: "element-tag", severity: :warning, line: line,
|
|
91
|
+
message: "#{written} hands its hash over as a splat, which a host compiling with " \
|
|
92
|
+
"Herb's slots drops from the element - pass it as an argument: " \
|
|
93
|
+
"tag.attributes(#{splat_source(call)})")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# What a slot compile gets wrong about a call, in words, or nil.
|
|
97
|
+
def problem_with(call)
|
|
98
|
+
if call.name == :content_tag && !literal_name?(call)
|
|
99
|
+
"takes its tag name from a variable, which stops the template compiling in a host " \
|
|
100
|
+
"that compiles with Herb's slots"
|
|
101
|
+
elsif splat?(call) || variable_options?(call)
|
|
102
|
+
"takes attributes a host compiling with Herb's slots cannot read, and drops them from the element"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Whether `content_tag` names its tag with a symbol or a string literal.
|
|
107
|
+
def literal_name?(call)
|
|
108
|
+
first = positional(call).first
|
|
109
|
+
|
|
110
|
+
first.is_a?(Prism::SymbolNode) || first.is_a?(Prism::StringNode)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Whether a call hands over a double splat.
|
|
114
|
+
def splat?(call)
|
|
115
|
+
keywords(call).any?(Prism::AssocSplatNode)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Whether a block call takes its attributes as a variable: with a
|
|
119
|
+
# block, the argument after the tag name is the options.
|
|
120
|
+
def variable_options?(call)
|
|
121
|
+
return false unless call.block
|
|
122
|
+
|
|
123
|
+
options = call.name == :content_tag ? positional(call)[1] : positional(call)[0]
|
|
124
|
+
!options.nil? && !options.is_a?(Prism::HashNode)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The arguments that are not keywords.
|
|
128
|
+
def positional(call)
|
|
129
|
+
(call.arguments&.arguments || []).grep_v(Prism::KeywordHashNode)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The elements of the keyword hash, splats among them.
|
|
133
|
+
def keywords(call)
|
|
134
|
+
(call.arguments&.arguments || []).grep(Prism::KeywordHashNode).flat_map(&:elements)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The call as it is written, up to its arguments.
|
|
138
|
+
def written(call)
|
|
139
|
+
call.receiver ? "tag.#{call.name}" : "content_tag"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The `element_tag` call to write instead.
|
|
143
|
+
def replacement(call)
|
|
144
|
+
return "element_tag(:#{call.name}, ...)" if call.receiver
|
|
145
|
+
|
|
146
|
+
"element_tag(#{positional(call).first&.slice || ":name"}, ...)"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The expression a splat spreads.
|
|
150
|
+
def splat_source(call)
|
|
151
|
+
keywords(call).grep(Prism::AssocSplatNode).first&.value&.slice || "attributes"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
data/lib/poetry/core/check.rb
CHANGED
|
@@ -197,12 +197,16 @@ module Poetry
|
|
|
197
197
|
# that yields its slot builder.
|
|
198
198
|
def helper_yields?(helper) = !@helper_entries.dig(helper, "yields").nil?
|
|
199
199
|
|
|
200
|
-
# A helper whose component entry declares "identity" => false
|
|
201
|
-
#
|
|
202
|
-
#
|
|
203
|
-
#
|
|
204
|
-
#
|
|
205
|
-
|
|
200
|
+
# A helper whose component entry declares "identity" => false, or
|
|
201
|
+
# a pathless helper whose registry helpers-section entry declares
|
|
202
|
+
# it (a table part helper stamps no poetry-minted id): its rendered
|
|
203
|
+
# output carries no random id, so key:/id: have nothing to
|
|
204
|
+
# stabilize and the stable-identity rules skip it. Legacy entries
|
|
205
|
+
# (no key) read as minting - their warnings stand.
|
|
206
|
+
def identity_free?(helper)
|
|
207
|
+
@components.dig(@path_by_helper[helper], "identity") == false ||
|
|
208
|
+
@helper_entries.dig(helper, "identity") == false
|
|
209
|
+
end
|
|
206
210
|
|
|
207
211
|
# The element-level wiring projection: which elements of
|
|
208
212
|
# a component carry which controllers, values, actions, and
|
|
@@ -419,10 +423,11 @@ module Poetry
|
|
|
419
423
|
node.class.name.include?("ERB") && !erb_comment?(node)
|
|
420
424
|
end
|
|
421
425
|
|
|
422
|
-
# <%# ... %>
|
|
423
|
-
# the tag opening tells prose from
|
|
424
|
-
# helper ("a plain poetry_input
|
|
425
|
-
# as Ruby (the helper-arity
|
|
426
|
+
# <%# ... %> carries content like any output tag, whichever node
|
|
427
|
+
# class the parser gives it - only the tag opening tells prose from
|
|
428
|
+
# code. Comment text mentioning a helper ("a plain poetry_input
|
|
429
|
+
# chromes it") must never reach Prism as Ruby (the helper-arity
|
|
430
|
+
# false-positive class).
|
|
426
431
|
def erb_comment?(node)
|
|
427
432
|
node.respond_to?(:tag_opening) && node.tag_opening&.value == "<%#"
|
|
428
433
|
end
|
|
@@ -1373,9 +1378,10 @@ module Poetry
|
|
|
1373
1378
|
# A click target that is not a button: an inert element carrying
|
|
1374
1379
|
# onclick or wearing role=button. A real <button> brings focus,
|
|
1375
1380
|
# keyboard activation, and the role for free, which is the first
|
|
1376
|
-
# rule of ARIA. Anchors stay out (a
|
|
1377
|
-
#
|
|
1378
|
-
#
|
|
1381
|
+
# rule of ARIA. Anchors stay out (a link is a link - the Button's
|
|
1382
|
+
# anchor form renders no role, and an author who adds one has
|
|
1383
|
+
# chosen it), and so does a Stimulus click on a bare div - a
|
|
1384
|
+
# backdrop or a row is a legitimate click target without a role.
|
|
1379
1385
|
def fake_button_findings(node)
|
|
1380
1386
|
tag = open_tag_name(node)
|
|
1381
1387
|
attributes = node.child_nodes.compact.grep(Herb::AST::HTMLAttributeNode)
|
|
@@ -1656,11 +1662,21 @@ module Poetry
|
|
|
1656
1662
|
# @api private
|
|
1657
1663
|
class Runner
|
|
1658
1664
|
# A runner over the catalog's linters.
|
|
1659
|
-
|
|
1665
|
+
#
|
|
1666
|
+
# @param catalog [Catalog] the component catalog to validate against
|
|
1667
|
+
# @param compile [Compile, nil] the compile tier, which a host's
|
|
1668
|
+
# check hands over and a gem's own leaves to its compile gate
|
|
1669
|
+
# @param renderable [RenderableReaders] the renderable rule for
|
|
1670
|
+
# component classes, an error where the host's Rails renders every
|
|
1671
|
+
# view through the renderer
|
|
1672
|
+
def initialize(catalog, compile: nil, renderable: RenderableReaders.new)
|
|
1660
1673
|
@linter = Linter.new(catalog)
|
|
1661
1674
|
@declarations = IconDeclarations.new(catalog)
|
|
1662
1675
|
@internals = InternalConstants.new(catalog)
|
|
1663
1676
|
@stable_identity = StableIdentity.new(catalog)
|
|
1677
|
+
@tag_helpers = TagHelpers.new
|
|
1678
|
+
@compile = compile
|
|
1679
|
+
@renderable = renderable
|
|
1664
1680
|
end
|
|
1665
1681
|
|
|
1666
1682
|
# Every finding across the paths, each stamped with its file.
|
|
@@ -1683,12 +1699,19 @@ module Poetry
|
|
|
1683
1699
|
return [Finding.new(rule: "unreadable", severity: :warning,
|
|
1684
1700
|
message: "not valid UTF-8 - skipped (re-encode the file to lint it)")]
|
|
1685
1701
|
end
|
|
1686
|
-
|
|
1702
|
+
if path.end_with?(".rb")
|
|
1703
|
+
return @declarations.lint(source) + @internals.lint(source) + @renderable.lint(source)
|
|
1704
|
+
end
|
|
1687
1705
|
|
|
1688
1706
|
findings = @linter.lint(source, mail: Check.mail_template?(path, root: root))
|
|
1689
1707
|
# The StableId heuristics ride every ERB pass (warnings only -
|
|
1690
|
-
# they never flip the exit code)
|
|
1691
|
-
|
|
1708
|
+
# they never flip the exit code); the element rule rides a
|
|
1709
|
+
# component's template, and so does the compile tier when the
|
|
1710
|
+
# runner was handed one.
|
|
1711
|
+
findings += @stable_identity.lint(source)
|
|
1712
|
+
findings += @tag_helpers.lint(source) if Check.component_template?(path)
|
|
1713
|
+
findings += @compile.lint(source, filename: path) if @compile && Compile.html_template?(path)
|
|
1714
|
+
findings
|
|
1692
1715
|
rescue SystemCallError, IOError, ArgumentError, Encoding::CompatibilityError => e
|
|
1693
1716
|
[Finding.new(rule: "unreadable", severity: :warning, message: "#{e.class}: #{e.message} - skipped")]
|
|
1694
1717
|
end
|
|
@@ -1702,8 +1725,21 @@ module Poetry
|
|
|
1702
1725
|
# leave these alone.
|
|
1703
1726
|
MAIL_TEMPLATE = %r{(?:\A|/)(?:[a-z0-9_]+_mailer|layouts/mailer|devise/mailer)(?:/|\.)}
|
|
1704
1727
|
|
|
1728
|
+
# A component's template by convention: an ERB file under
|
|
1729
|
+
# `app/components/`, where a sidecar template lives.
|
|
1730
|
+
COMPONENT_TEMPLATE = %r{(?:\A|/)app/components/.+\.erb\z}
|
|
1731
|
+
|
|
1705
1732
|
module_function
|
|
1706
1733
|
|
|
1734
|
+
# Whether a template path is a component's template by convention:
|
|
1735
|
+
# an ERB file under `app/components/`.
|
|
1736
|
+
#
|
|
1737
|
+
# @param path [String]
|
|
1738
|
+
# @return [Boolean]
|
|
1739
|
+
def component_template?(path)
|
|
1740
|
+
COMPONENT_TEMPLATE.match?(path.to_s)
|
|
1741
|
+
end
|
|
1742
|
+
|
|
1707
1743
|
# Whether a template path is a mailer template by convention: a view
|
|
1708
1744
|
# under a `*_mailer/` directory (Devise's `devise/mailer/` included)
|
|
1709
1745
|
# or the mailer layout. With a root, only the path inside it counts,
|
data/lib/poetry/core/engine.rb
CHANGED
|
@@ -44,14 +44,23 @@ module Poetry
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
# The app's and its engines' own components' helpers (`helper :name`):
|
|
47
|
-
# defined
|
|
48
|
-
#
|
|
47
|
+
# defined before the first view renders and again on every reload, so a
|
|
48
|
+
# view can call one before anything else referenced the class.
|
|
49
|
+
#
|
|
50
|
+
# The components are loaded to find the helpers, and a component is a
|
|
51
|
+
# view. Loading them while the framework is still initializing loads
|
|
52
|
+
# Action View early, which Rails reports and, when asked, raises on.
|
|
53
|
+
# Loading them from Action View's own load hook is no better: when a
|
|
54
|
+
# component class is what loads Action View, the classes are half
|
|
55
|
+
# defined. So a host that eager loads syncs once its boot is done, with
|
|
56
|
+
# the classes already loaded, and every other host syncs on its first
|
|
57
|
+
# view context, at render time. Each reload syncs from `to_prepare`.
|
|
49
58
|
initializer "poetry_core.host_helpers" do |app|
|
|
50
|
-
app.config.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
app.config.after_initialize { Poetry::Core::HostHelpers.sync_now! if app.config.eager_load }
|
|
60
|
+
app.config.to_prepare { Poetry::Core::HostHelpers.sync_now! if Poetry::Core::HostHelpers.ready? }
|
|
61
|
+
ActiveSupport.on_load(:action_view) do
|
|
62
|
+
include Poetry::Core::HostHelpers unless include?(Poetry::Core::HostHelpers)
|
|
63
|
+
prepend Poetry::Core::HostHelpers::FirstView unless self < Poetry::Core::HostHelpers::FirstView
|
|
55
64
|
end
|
|
56
65
|
end
|
|
57
66
|
|
|
@@ -15,7 +15,45 @@ module Poetry
|
|
|
15
15
|
#
|
|
16
16
|
# @api private
|
|
17
17
|
module HostHelpers
|
|
18
|
+
# Prepended to Action View's base class by the engine: the first view
|
|
19
|
+
# context of a host that did not eager load syncs the helpers, so the
|
|
20
|
+
# template about to render can call one. After that it is one
|
|
21
|
+
# comparison per view.
|
|
22
|
+
#
|
|
23
|
+
# @api private
|
|
24
|
+
module FirstView
|
|
25
|
+
# The view context, with the helpers defined first.
|
|
26
|
+
def initialize(...)
|
|
27
|
+
Poetry::Core::HostHelpers.sync_now! unless Poetry::Core::HostHelpers.ready?
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
SYNC = Mutex.new
|
|
33
|
+
private_constant :SYNC
|
|
34
|
+
|
|
18
35
|
class << self
|
|
36
|
+
# Discovers the app's components and defines their helpers: the
|
|
37
|
+
# engine's sync, at the end of an eager loading boot, at the first
|
|
38
|
+
# view otherwise, and on every reload. One at a time, since two views
|
|
39
|
+
# can be the first on two threads.
|
|
40
|
+
#
|
|
41
|
+
# @return [Array<String>] the helper names now defined
|
|
42
|
+
def sync_now!
|
|
43
|
+
SYNC.synchronize do
|
|
44
|
+
names = sync!(HostComponents.discover)
|
|
45
|
+
@ready = true
|
|
46
|
+
names
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Whether the first sync has happened, so a reload may sync again.
|
|
51
|
+
#
|
|
52
|
+
# @return [Boolean]
|
|
53
|
+
def ready?
|
|
54
|
+
@ready == true
|
|
55
|
+
end
|
|
56
|
+
|
|
19
57
|
# Defines the helpers of `components` and removes the helpers of
|
|
20
58
|
# components that no longer declare one (a rename, a deletion).
|
|
21
59
|
# Every name is checked before any is defined, so a clash leaves
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Core
|
|
5
|
+
# What a slot compile writes around a template's own markup, and how to
|
|
6
|
+
# take it off again.
|
|
7
|
+
#
|
|
8
|
+
# A host that compiles its templates with Herb's slots gets every
|
|
9
|
+
# dynamic part of the page marked, so the browser can find it again:
|
|
10
|
+
# comments around a rendering and around each slot, a `data-herb-slot`
|
|
11
|
+
# attribute on an element that carries one, and the branches that did
|
|
12
|
+
# not render, parked in a `<template>` behind the rendering. None of it
|
|
13
|
+
# is the component's. Taken off, what is left is the markup the template
|
|
14
|
+
# wrote, which is what a test or a contract means to compare.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# html = render_inline(Poetry::Ui::Button::Component.new) { "Save" }.to_html
|
|
18
|
+
# Poetry::Core::SlotMarkers.strip(html) # => the button, as a plain host renders it
|
|
19
|
+
module SlotMarkers
|
|
20
|
+
# A marker comment: a region, a slot, a branch or the seeds, opening
|
|
21
|
+
# or closing.
|
|
22
|
+
COMMENT = %r{<!--/?herb-[a-z]+\b.*?-->}m
|
|
23
|
+
|
|
24
|
+
# The attribute on an element that carries a slot.
|
|
25
|
+
ATTRIBUTE = / data-herb-slot="[^"]*"/
|
|
26
|
+
|
|
27
|
+
# Where a parked block opens: branches under `data-herb-region`, the
|
|
28
|
+
# state map under `data-herb-dependencies`.
|
|
29
|
+
PARKED = /<template data-herb-(?:region|dependencies)\b[^>]*>/
|
|
30
|
+
|
|
31
|
+
# Either end of a template element.
|
|
32
|
+
TEMPLATE_EDGE = %r{<template\b[^>]*>|</template>}
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# The markup without anything a slot compile added. Markup that
|
|
36
|
+
# carries none of it comes back as it went in.
|
|
37
|
+
#
|
|
38
|
+
# @param html [String] rendered markup
|
|
39
|
+
# @return [String] the markup the templates wrote, html-safe when
|
|
40
|
+
# what it was given was
|
|
41
|
+
def strip(html)
|
|
42
|
+
source = html.to_s
|
|
43
|
+
return html unless source.include?("herb-")
|
|
44
|
+
|
|
45
|
+
stripped = without_parked(source).gsub(COMMENT, "").gsub(ATTRIBUTE, "")
|
|
46
|
+
html.respond_to?(:html_safe?) && html.html_safe? ? stripped.html_safe : stripped
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# The markup without its parked blocks. A parked branch may hold a
|
|
52
|
+
# template element of its own, so the end of a block is the closing
|
|
53
|
+
# tag that balances its opening one.
|
|
54
|
+
def without_parked(html)
|
|
55
|
+
kept = +""
|
|
56
|
+
rest = html
|
|
57
|
+
while (opening = rest.match(PARKED))
|
|
58
|
+
kept << opening.pre_match
|
|
59
|
+
rest = after_block(opening.post_match)
|
|
60
|
+
end
|
|
61
|
+
kept << rest
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# What follows the template element whose opening tag was just read.
|
|
65
|
+
def after_block(html)
|
|
66
|
+
depth = 1
|
|
67
|
+
rest = html
|
|
68
|
+
while depth.positive? && (edge = rest.match(TEMPLATE_EDGE))
|
|
69
|
+
depth += edge[0].start_with?("</") ? -1 : 1
|
|
70
|
+
rest = edge.post_match
|
|
71
|
+
end
|
|
72
|
+
depth.zero? ? rest : ""
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -3,8 +3,50 @@
|
|
|
3
3
|
module Poetry
|
|
4
4
|
module Core
|
|
5
5
|
# The view-helper seam: included into ActionView by the engine
|
|
6
|
-
# (initializer "poetry_core.tag_helper")
|
|
6
|
+
# (initializer "poetry_core.tag_helper") and into every component, so a
|
|
7
|
+
# view and a component's template build an element the same way.
|
|
7
8
|
module TagHelper
|
|
9
|
+
# The elements that take no content and no closing tag: {#element_tag}
|
|
10
|
+
# renders these through `tag` and every other name through
|
|
11
|
+
# `content_tag`.
|
|
12
|
+
VOID_ELEMENTS = %w[area base br col embed hr img input link meta source track wbr].freeze
|
|
13
|
+
|
|
14
|
+
# An element from a tag name and its attributes: where a component's
|
|
15
|
+
# template spends what `root_attributes` and `element_attributes`
|
|
16
|
+
# build. It takes what `content_tag` takes and renders what
|
|
17
|
+
# `content_tag` renders; a void element renders the way `tag.input`
|
|
18
|
+
# does.
|
|
19
|
+
#
|
|
20
|
+
# A component's template builds every element through this method,
|
|
21
|
+
# and never through `content_tag` or `tag.div` themselves. A host may
|
|
22
|
+
# compile its templates with Herb's slots, and that compile resolves
|
|
23
|
+
# Action View's own tag helpers into markup ahead of the render: a
|
|
24
|
+
# splat or a tag name held in a variable is more than it can read,
|
|
25
|
+
# and the element comes out without its attributes, or the template
|
|
26
|
+
# stops compiling. A method it does not know stays a call, so the
|
|
27
|
+
# element reaches the page as it was built. `tag.attributes` is the
|
|
28
|
+
# one helper a template still calls itself, for attributes written
|
|
29
|
+
# inside a literal tag, and it takes its hash as an argument, never as
|
|
30
|
+
# a splat. A view holds to the same rule wherever it hands a tag
|
|
31
|
+
# helper a splat.
|
|
32
|
+
#
|
|
33
|
+
# @param name [Symbol, String] the tag name
|
|
34
|
+
# @param arguments [Array] the content, when no block gives it, and
|
|
35
|
+
# the attributes when they arrive as a Hash
|
|
36
|
+
# @param attributes [Hash] what `root_attributes` or a part builder
|
|
37
|
+
# returned, splatted, or the attributes written out as keywords
|
|
38
|
+
# @return [ActiveSupport::SafeBuffer] the element
|
|
39
|
+
# @example A root, a part and a void element
|
|
40
|
+
# <%= element_tag(:button, **root_attributes) do %>
|
|
41
|
+
# <%= element_tag(:span, label, **element_attributes(:label)) %>
|
|
42
|
+
# <%= element_tag(:input, **input_attributes) %>
|
|
43
|
+
# <% end %>
|
|
44
|
+
def element_tag(name, *arguments, **attributes, &)
|
|
45
|
+
return content_tag(name, *arguments, **attributes, &) unless VOID_ELEMENTS.include?(name.to_s)
|
|
46
|
+
|
|
47
|
+
given = arguments.last.is_a?(Hash) ? arguments.last : {}
|
|
48
|
+
tag.public_send(name, **given, **attributes)
|
|
49
|
+
end
|
|
8
50
|
end
|
|
9
51
|
end
|
|
10
52
|
end
|
|
@@ -3,15 +3,29 @@
|
|
|
3
3
|
module Poetry
|
|
4
4
|
module Core
|
|
5
5
|
# The Herb COMPILE gate: every ERB template is fed through
|
|
6
|
-
# `Herb::Engine` - the
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
6
|
+
# `Herb::Engine` - the compiler Rails routes HTML templates through
|
|
7
|
+
# under its 8.2 framework defaults - so a template that parses but
|
|
8
|
+
# refuses to compile (ERB output in an attribute name, bare output in
|
|
9
|
+
# attribute position, an element nested where the validators forbid it)
|
|
10
|
+
# fails poetry's CI instead of the host's first request.
|
|
11
|
+
#
|
|
12
|
+
# The posture is the strictest one a host can run. The engine validates
|
|
13
|
+
# nothing unless it is handed validators, so the gate hands it every
|
|
14
|
+
# validator it ships, each one fatal, and has the compiled Ruby checked
|
|
15
|
+
# for syntax. Rails compiles without validators and its `herb:check`
|
|
16
|
+
# task adds the security one; a host that compiles gem templates with
|
|
17
|
+
# every validator raising is the far end. A template that passes here
|
|
18
|
+
# passes all of them. The validators are named in code, never read from
|
|
19
|
+
# `.herb.yml`, so no configuration can hollow the gate.
|
|
20
|
+
#
|
|
21
|
+
# A second posture, `:rails`, is the one `bin/rails herb:check` holds an
|
|
22
|
+
# app's views to: the security validator alone. `poetry:check` compiles
|
|
23
|
+
# component templates in it, since that task walks the view paths and
|
|
24
|
+
# never reaches them.
|
|
11
25
|
#
|
|
12
26
|
# Parsing clean (see CSS::TemplateClasses, the parse gate) and
|
|
13
|
-
# compiling clean are different contracts: the
|
|
14
|
-
# the parser
|
|
27
|
+
# compiling clean are different contracts: the validators refuse shapes
|
|
28
|
+
# the parser accepts.
|
|
15
29
|
#
|
|
16
30
|
# Herb is loaded lazily: it is a build/CI-time tool, not a runtime
|
|
17
31
|
# dependency of the gem.
|
|
@@ -26,6 +40,20 @@ module Poetry
|
|
|
26
40
|
# copied verbatim into hosts. Rails renders both through the engine.
|
|
27
41
|
DEFAULT_GLOBS = ["app/**/*.erb", "lib/generators/**/templates/**/*.erb"].freeze
|
|
28
42
|
|
|
43
|
+
# The oldest Herb the gate runs on: from this version the engine takes
|
|
44
|
+
# its validators as visitors and raises compile errors as syntax errors.
|
|
45
|
+
MINIMUM_HERB = "0.11.0"
|
|
46
|
+
|
|
47
|
+
# The validators of each posture, by their names in the engine.
|
|
48
|
+
POSTURES = {
|
|
49
|
+
strict: %i[security nesting accessibility render generator_template],
|
|
50
|
+
rails: %i[security]
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
# A template the engine refused: its message, and the line the engine
|
|
54
|
+
# points at when it names one.
|
|
55
|
+
Failure = Struct.new(:message, :line)
|
|
56
|
+
|
|
29
57
|
# A template Herb's engine could not compile, with its message.
|
|
30
58
|
CompileError = Struct.new(:path, :message) do
|
|
31
59
|
# The path and the message.
|
|
@@ -39,25 +67,50 @@ module Poetry
|
|
|
39
67
|
|
|
40
68
|
class << self
|
|
41
69
|
# Compiles one ERB source string, returning the engine's error
|
|
42
|
-
# message or nil when it compiles.
|
|
70
|
+
# message or nil when it compiles. A missing or outdated herb gem
|
|
71
|
+
# raises instead: that is a broken setup, not a finding about the
|
|
72
|
+
# template.
|
|
43
73
|
#
|
|
74
|
+
# @param source [String] the template
|
|
75
|
+
# @param filename [String] the path the engine opens its message with
|
|
76
|
+
# @param posture [Symbol] :strict or :rails
|
|
44
77
|
# @return [String, nil]
|
|
45
|
-
def compile(source, filename: "template.html.erb")
|
|
78
|
+
def compile(source, filename: "template.html.erb", posture: :strict)
|
|
79
|
+
failure(source, filename: filename, posture: posture)&.message
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Compiles one ERB source string, returning what the engine refused
|
|
83
|
+
# it for, or nil when it compiles.
|
|
84
|
+
#
|
|
85
|
+
# @param source [String] the template
|
|
86
|
+
# @param filename [String] the path the engine opens its message with
|
|
87
|
+
# @param posture [Symbol] :strict or :rails
|
|
88
|
+
# @return [Failure, nil]
|
|
89
|
+
def failure(source, filename: "template.html.erb", posture: :strict)
|
|
46
90
|
herb!
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
91
|
+
visitors = validators(posture)
|
|
92
|
+
|
|
93
|
+
begin
|
|
94
|
+
Herb::Engine.new(source, filename: filename, validate_ruby: true, visitors: visitors)
|
|
95
|
+
nil
|
|
96
|
+
rescue StandardError, SyntaxError => e
|
|
97
|
+
# The engine raises its compile and parse errors as syntax
|
|
98
|
+
# errors, which a bare rescue would let through.
|
|
99
|
+
Failure.new(e.message, line_of(e))
|
|
100
|
+
end
|
|
51
101
|
end
|
|
52
102
|
|
|
53
103
|
# Compiles every template under root matching the globs.
|
|
54
104
|
#
|
|
105
|
+
# @param root [String, Pathname] the directory the globs are read under
|
|
106
|
+
# @param globs [Array<String>] the templates to compile
|
|
107
|
+
# @param posture [Symbol] :strict or :rails
|
|
55
108
|
# @return [Result] compiled (Integer, templates that compiled) + errors (Array<CompileError>)
|
|
56
|
-
def check(root:, globs: DEFAULT_GLOBS)
|
|
109
|
+
def check(root:, globs: DEFAULT_GLOBS, posture: :strict)
|
|
57
110
|
errors = []
|
|
58
111
|
paths = globs.flat_map { |glob| Dir.glob(glob, base: root.to_s) }.uniq.sort
|
|
59
112
|
paths.each do |relative|
|
|
60
|
-
message = compile(File.read(File.join(root, relative)), filename: relative)
|
|
113
|
+
message = compile(File.read(File.join(root, relative)), filename: relative, posture: posture)
|
|
61
114
|
errors << CompileError.new(relative, message) if message
|
|
62
115
|
end
|
|
63
116
|
Result.new(paths.size - errors.size, errors)
|
|
@@ -65,12 +118,51 @@ module Poetry
|
|
|
65
118
|
|
|
66
119
|
private
|
|
67
120
|
|
|
68
|
-
#
|
|
121
|
+
# The validators of a posture, each one fatal. Built per compile,
|
|
122
|
+
# since a validator keeps what it found. The strict posture is every
|
|
123
|
+
# validator the engine ships, so one a later release adds joins it.
|
|
124
|
+
def validators(posture = :strict)
|
|
125
|
+
names = POSTURES.fetch(posture) do
|
|
126
|
+
raise ArgumentError, "unknown posture #{posture.inspect} - one of #{POSTURES.keys.join(", ")}"
|
|
127
|
+
end
|
|
128
|
+
shipped = Herb::Engine::Validators::ALL
|
|
129
|
+
chosen = posture == :strict ? shipped.values : shipped.values_at(*names)
|
|
130
|
+
chosen.map { |validator| validator.new(fatal: true) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The line an engine error points at: a validator's error carries
|
|
134
|
+
# it, a parse error carries it on its first diagnostic.
|
|
135
|
+
def line_of(error)
|
|
136
|
+
return error.line if error.respond_to?(:line) && error.line
|
|
137
|
+
return unless error.respond_to?(:diagnostics)
|
|
138
|
+
|
|
139
|
+
location = error.diagnostics.first&.location
|
|
140
|
+
location&.start&.line
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Requires the herb gem with its engine and validators, raising a
|
|
144
|
+
# clear error when the gem is missing or too old for the gate.
|
|
69
145
|
def herb!
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
146
|
+
begin
|
|
147
|
+
require "herb"
|
|
148
|
+
rescue LoadError
|
|
149
|
+
raise Poetry::Core::Error,
|
|
150
|
+
"the herb gem is required for the template compile gate - add `gem \"herb\"` to your Gemfile"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
unless supported?(Herb::VERSION)
|
|
154
|
+
raise Poetry::Core::Error,
|
|
155
|
+
"the template compile gate needs herb #{MINIMUM_HERB} or newer, found #{Herb::VERSION} - " \
|
|
156
|
+
"run `bundle update herb`"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
require "herb/engine"
|
|
160
|
+
require "herb/engine/validators"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Whether a herb version carries the engine the gate is written for.
|
|
164
|
+
def supported?(version)
|
|
165
|
+
Gem::Version.new(version) >= Gem::Version.new(MINIMUM_HERB)
|
|
74
166
|
end
|
|
75
167
|
end
|
|
76
168
|
end
|
data/lib/poetry/core/version.rb
CHANGED
data/lib/poetry/core.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "zeitwerk"
|
|
|
5
5
|
require "active_support/concern"
|
|
6
6
|
require "active_model/type/symbol"
|
|
7
7
|
require "active_model/type/list"
|
|
8
|
+
require "active_model/type/html_safe_string"
|
|
8
9
|
|
|
9
10
|
ActiveModel::Type.register(:symbol, ActiveModel::Type::Symbol)
|
|
10
11
|
ActiveModel::Type.register(:list, ActiveModel::Type::List)
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: poetry-core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Matt Solt
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-09-
|
|
10
|
+
date: 2026-09-30 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: rails
|
|
@@ -180,13 +180,17 @@ files:
|
|
|
180
180
|
- config/importmap.rb
|
|
181
181
|
- config/state_vocabulary.json
|
|
182
182
|
- eslint.config.mjs
|
|
183
|
+
- lib/active_model/type/html_safe_string.rb
|
|
183
184
|
- lib/active_model/type/list.rb
|
|
184
185
|
- lib/active_model/type/symbol.rb
|
|
185
186
|
- lib/poetry-core.rb
|
|
186
187
|
- lib/poetry/core.rb
|
|
187
188
|
- lib/poetry/core/api_internals.rb
|
|
188
189
|
- lib/poetry/core/check.rb
|
|
190
|
+
- lib/poetry/core/check/compile.rb
|
|
191
|
+
- lib/poetry/core/check/renderable_readers.rb
|
|
189
192
|
- lib/poetry/core/check/stable_identity.rb
|
|
193
|
+
- lib/poetry/core/check/tag_helpers.rb
|
|
190
194
|
- lib/poetry/core/config.rb
|
|
191
195
|
- lib/poetry/core/contrib/wrapped_helper.rb
|
|
192
196
|
- lib/poetry/core/css/bem_merger.rb
|
|
@@ -225,6 +229,7 @@ files:
|
|
|
225
229
|
- lib/poetry/core/registry_items.rb
|
|
226
230
|
- lib/poetry/core/requires_any.rb
|
|
227
231
|
- lib/poetry/core/skill_text.rb
|
|
232
|
+
- lib/poetry/core/slot_markers.rb
|
|
228
233
|
- lib/poetry/core/stable_id.rb
|
|
229
234
|
- lib/poetry/core/stimulus/builder.rb
|
|
230
235
|
- lib/poetry/core/stimulus/declarations.rb
|