senren-ui 0.1.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +104 -0
- data/CONTRIBUTING.md +40 -3
- data/README.md +110 -33
- data/Rakefile +14 -1
- data/docs/components.md +10 -10
- data/docs/hot_reload.md +103 -0
- data/docs/performance_testing.md +7 -3
- data/lib/generators/senren/component/templates/controller.js.tt +7 -4
- data/lib/generators/senren/install/install_generator.rb +74 -0
- data/lib/generators/senren/install/templates/base_component.rb.tt +52 -4
- data/lib/generators/senren/install/templates/conventions.md.tt +16 -1
- data/lib/senren/rails/agent_rules_writer.rb +62 -19
- data/lib/senren/rails/asset_path_guard.rb +128 -0
- data/lib/senren/rails/base_component_patch.rb +64 -0
- data/lib/senren/rails/component_copier.rb +101 -50
- data/lib/senren/rails/component_installer.rb +26 -0
- data/lib/senren/rails/doctor.rb +7 -4
- data/lib/senren/rails/engine.rb +23 -0
- data/lib/senren/rails/host_paths.rb +11 -2
- data/lib/senren/rails/marker_block.rb +81 -0
- data/lib/senren/rails/registry.rb +10 -4
- data/lib/senren/rails/safe_write.rb +169 -0
- data/lib/senren/rails/skill_writer.rb +47 -11
- data/lib/senren/rails/version.rb +1 -1
- data/lib/senren/rails.rb +1 -1
- data/lib/senren-ui.rb +15 -0
- data/lib/tasks/senren.rake +43 -17
- data/registry/components.yml +45 -0
- data/registry/recipes.yml +12 -0
- data/templates/components/alert_dialog/alert_dialog_component.rb +1 -1
- data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
- data/templates/components/aspect_ratio/aspect_ratio_component.rb +7 -0
- data/templates/components/avatar/avatar_component.rb +8 -1
- data/templates/components/cart/cart_component.html.erb +61 -0
- data/templates/components/cart/cart_component.rb +71 -0
- data/templates/components/checkbox/checkbox_component.rb +1 -1
- data/templates/components/clipboard/clipboard_component.html.erb +1 -1
- data/templates/components/command/command_component.rb +1 -1
- data/templates/components/date_picker/date_picker_component.html.erb +1 -1
- data/templates/components/dialog/dialog_component.rb +1 -1
- data/templates/components/form/form_component.rb +9 -1
- data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
- data/templates/components/product_card/product_card_component.html.erb +38 -0
- data/templates/components/product_card/product_card_component.rb +49 -0
- data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +1 -1
- data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +1 -1
- data/templates/components/separator/separator_component.rb +7 -0
- data/templates/components/sheet/sheet_component.rb +1 -1
- data/templates/components/tooltip/tooltip_component.rb +2 -2
- data/templates/components/typography/typography_component.rb +7 -0
- data/templates/controllers/accordion_controller.js +1 -1
- data/templates/controllers/alert_dialog_controller.js +31 -7
- data/templates/controllers/cart_controller.js +83 -0
- data/templates/controllers/clipboard_controller.js +12 -1
- data/templates/controllers/command_controller.js +3 -4
- data/templates/controllers/context_menu_controller.js +38 -11
- data/templates/controllers/data_table_controller.js +8 -3
- data/templates/controllers/dialog_controller.js +47 -21
- data/templates/controllers/dropdown_menu_controller.js +40 -27
- data/templates/controllers/hover_card_controller.js +8 -0
- data/templates/controllers/invite_member_dialog_controller.js +6 -0
- data/templates/controllers/masked_input_controller.js +8 -1
- data/templates/controllers/popover_controller.js +25 -10
- data/templates/controllers/rich_text_editor_lite_controller.js +165 -28
- data/templates/controllers/sheet_controller.js +41 -11
- metadata +13 -17
- data/lib/senren/rails/installer.rb +0 -85
|
@@ -38,6 +38,27 @@ module Senren
|
|
|
38
38
|
copy_file Senren::Rails.registry_path, '.senren/registry.yml'
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
# Switches Stimulus to on-demand loading instead of documenting it.
|
|
42
|
+
#
|
|
43
|
+
# Rails' default is `eagerLoadControllersFrom("controllers", application)`,
|
|
44
|
+
# which imports every controller in the importmap on every page. Because
|
|
45
|
+
# `pin_all_from "app/javascript/controllers"` is recursive it also covers
|
|
46
|
+
# app/javascript/controllers/senren, so a static page paid for every
|
|
47
|
+
# interactive component the app had installed.
|
|
48
|
+
#
|
|
49
|
+
# This was a README instruction the developer had to follow by hand, and a
|
|
50
|
+
# "PASS" in bin/performance that only grepped that README. An instruction
|
|
51
|
+
# nobody runs is not a feature.
|
|
52
|
+
#
|
|
53
|
+
# It uses the official stimulus-loading helper rather than a Senren-specific
|
|
54
|
+
# loader, and therefore changes loading for the app's own controllers too.
|
|
55
|
+
# That is the trade the README already asked for; the generator only acts
|
|
56
|
+
# when the file still carries the untouched Rails default, and says so.
|
|
57
|
+
def configure_stimulus_loading
|
|
58
|
+
enable_lazy_controller_loading
|
|
59
|
+
disable_controller_preloading
|
|
60
|
+
end
|
|
61
|
+
|
|
41
62
|
def write_skill_file
|
|
42
63
|
say_status :senren, 'writing .senren/skill.md'
|
|
43
64
|
Senren::Rails::SkillWriter.new(paths: host_paths).sync!
|
|
@@ -56,6 +77,59 @@ module Senren
|
|
|
56
77
|
|
|
57
78
|
private
|
|
58
79
|
|
|
80
|
+
def enable_lazy_controller_loading
|
|
81
|
+
index = 'app/javascript/controllers/index.js'
|
|
82
|
+
unless host_file?(index)
|
|
83
|
+
return say_status(:skip,
|
|
84
|
+
"#{index} not found; switch to lazyLoadControllersFrom by hand")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
source = File.read(File.join(destination_root, index))
|
|
88
|
+
if source.include?('lazyLoadControllersFrom')
|
|
89
|
+
return say_status(:senren,
|
|
90
|
+
'controllers already load on demand')
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
unless source.include?('eagerLoadControllersFrom')
|
|
94
|
+
return say_status(:skip, "#{index} has a custom loader; left alone")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
gsub_file index, 'eagerLoadControllersFrom', 'lazyLoadControllersFrom'
|
|
98
|
+
say_status :senren, 'Stimulus controllers now load when their data-controller appears'
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Only strips the modulepreload tags. On its own it does not stop eager
|
|
102
|
+
# importing, which is why it is paired with the change above.
|
|
103
|
+
def disable_controller_preloading
|
|
104
|
+
importmap = 'config/importmap.rb'
|
|
105
|
+
unless host_file?(importmap)
|
|
106
|
+
return say_status(:skip,
|
|
107
|
+
"#{importmap} not found; add preload: false by hand")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
source = File.read(File.join(destination_root, importmap))
|
|
111
|
+
if source.match?(/under:\s*["']controllers["'].*preload:\s*false/)
|
|
112
|
+
return say_status(:senren,
|
|
113
|
+
'controller preloading already disabled')
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if source.match?(%r{pin_all_from\s+["']app/javascript/controllers["']})
|
|
117
|
+
# A backreference rather than a block: Thor forwards the block to
|
|
118
|
+
# String#gsub across several frames, where $~ is no longer the match.
|
|
119
|
+
# The lookahead makes a second run a no-op.
|
|
120
|
+
gsub_file importmap,
|
|
121
|
+
%r{(pin_all_from\s+["']app/javascript/controllers["'](?![^\n]*preload:)[^\n]*)},
|
|
122
|
+
'\1, preload: false'
|
|
123
|
+
else
|
|
124
|
+
append_to_file importmap,
|
|
125
|
+
%(\npin_all_from "app/javascript/controllers", under: "controllers", preload: false\n)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def host_file?(relative)
|
|
130
|
+
File.exist?(File.join(destination_root, relative))
|
|
131
|
+
end
|
|
132
|
+
|
|
59
133
|
def host_paths
|
|
60
134
|
@host_paths ||= Senren::Rails::HostPaths.new(destination_root)
|
|
61
135
|
end
|
|
@@ -27,28 +27,76 @@ module Senren
|
|
|
27
27
|
|
|
28
28
|
# Compose final root attributes; subclasses pass their base classes.
|
|
29
29
|
def root_attrs(*classes, **extra)
|
|
30
|
-
data
|
|
30
|
+
# `data` arrives from two places: the caller's own attributes (html_attrs)
|
|
31
|
+
# and whatever the template passes here. Both are merged, and the
|
|
32
|
+
# component marker is applied last so it always survives.
|
|
33
|
+
#
|
|
34
|
+
# Splatting html_attrs after a computed `data:` used to overwrite it
|
|
35
|
+
# wholesale, so any component given a `data:` hash silently lost its
|
|
36
|
+
# data-senren-component marker. MaskedInput hit this on every render.
|
|
37
|
+
data = (html_attrs[:data] || {})
|
|
38
|
+
.merge(extra.delete(:data) || {})
|
|
39
|
+
.merge(senren_component: senren_component_name)
|
|
40
|
+
|
|
41
|
+
# `class` had exactly the same defect and it was never fixed alongside
|
|
42
|
+
# `data`. Every Rails tag helper accepts `class:`, so it is the first
|
|
43
|
+
# thing anyone types — and splatting html_attrs replaced the computed
|
|
44
|
+
# value outright, erasing the component's variant and size styling with
|
|
45
|
+
# no warning. It is merged like any other caller-supplied class instead,
|
|
46
|
+
# last so it still wins where the two genuinely conflict.
|
|
31
47
|
tag_class = merge_classes(
|
|
32
48
|
classes,
|
|
33
49
|
self.class::VARIANTS[@variant],
|
|
34
50
|
self.class::SIZES[@size],
|
|
35
51
|
@class_name,
|
|
36
|
-
extra.delete(:class)
|
|
52
|
+
extra.delete(:class),
|
|
53
|
+
html_attrs[:class]
|
|
37
54
|
)
|
|
38
|
-
{ class: tag_class, data: data, **html_attrs, **extra }
|
|
55
|
+
{ class: tag_class, data: data, **html_attrs.except(:data, :class), **extra }
|
|
39
56
|
end
|
|
40
57
|
|
|
41
58
|
def senren_component_name
|
|
42
59
|
self.class.name.to_s.sub(/^Senren::/, '').sub(/Component$/, '').gsub(/([a-z])([A-Z])/, '\1_\2').downcase
|
|
43
60
|
end
|
|
44
61
|
|
|
62
|
+
# Builds a DOM id from the arguments that identify this component, so the
|
|
63
|
+
# same inputs always produce the same id.
|
|
64
|
+
#
|
|
65
|
+
# Components used to append `SecureRandom.hex`, which made every render emit
|
|
66
|
+
# different HTML. That single fact disabled Turbo morph (it pairs nodes by
|
|
67
|
+
# id), HTTP ETags (the body never matched), fragment caching (cached and
|
|
68
|
+
# fresh fragments referenced different ids), and snapshot testing.
|
|
69
|
+
#
|
|
70
|
+
# Two components with identical identifying inputs on one page now produce
|
|
71
|
+
# the same id and the accessibility test fails. That is deliberate: the
|
|
72
|
+
# caller should pass an explicit `id:`. A random suffix hid the collision
|
|
73
|
+
# instead of resolving it.
|
|
74
|
+
def senren_dom_id(*parts)
|
|
75
|
+
seed = parts.flatten.map(&:to_s).reject(&:empty?).join('-')
|
|
76
|
+
base = "senren-#{senren_component_name.tr('_', '-')}"
|
|
77
|
+
return base if seed.empty?
|
|
78
|
+
|
|
79
|
+
"#{base}-#{seed.parameterize}"
|
|
80
|
+
end
|
|
81
|
+
|
|
45
82
|
private
|
|
46
83
|
|
|
47
84
|
def safe_url(value, fallback: '#', protocols: SAFE_URL_PROTOCOLS)
|
|
48
85
|
url = value.to_s.strip
|
|
49
86
|
return fallback if url.empty?
|
|
87
|
+
# Browsers treat "\" as "/" for special schemes and strip TAB/CR/LF
|
|
88
|
+
# before parsing, so "/\evil.example" and "/<TAB>/evil.example" would
|
|
89
|
+
# both slip past a plain "//" check and resolve off-origin.
|
|
90
|
+
return fallback if url.include?('\\')
|
|
91
|
+
return fallback if url.match?(/[[:cntrl:]]/)
|
|
50
92
|
return url if url.start_with?('#')
|
|
51
|
-
|
|
93
|
+
# Any leading "//" is protocol-relative regardless of how many slashes
|
|
94
|
+
# follow. Rejecting here rather than relying on URI.parse matters:
|
|
95
|
+
# URI.parse("///evil.example") reports no scheme and no host, so the
|
|
96
|
+
# scheme-less fallback below would otherwise hand back a URL the browser
|
|
97
|
+
# resolves to https://evil.example/.
|
|
98
|
+
return fallback if url.start_with?('//')
|
|
99
|
+
return url if url.start_with?('/')
|
|
52
100
|
|
|
53
101
|
uri = URI.parse(url)
|
|
54
102
|
return url if uri.scheme && Array(protocols).map(&:to_s).include?(uri.scheme.downcase)
|
|
@@ -8,7 +8,9 @@ and obey it strictly.
|
|
|
8
8
|
1. Use Senren components before writing custom HTML for the same purpose.
|
|
9
9
|
2. Server-render via ViewComponent. Stimulus only for **local** behavior.
|
|
10
10
|
3. Turbo handles server state. Controllers do not fetch/XHR from JS.
|
|
11
|
-
4.
|
|
11
|
+
4. Interactivity stays in Stimulus. These components render on the server, so
|
|
12
|
+
introducing a client-side framework alongside them puts two systems in
|
|
13
|
+
charge of the same state.
|
|
12
14
|
5. Tailwind classes must use **semantic tokens** (`bg-background`,
|
|
13
15
|
`text-foreground`, `bg-primary`, `text-muted-foreground`,
|
|
14
16
|
`border-border`, `bg-destructive`). Do not hard-code `gray-*`,
|
|
@@ -27,6 +29,19 @@ and obey it strictly.
|
|
|
27
29
|
<%% end %>
|
|
28
30
|
```
|
|
29
31
|
|
|
32
|
+
8. **Never put `app/components` on the asset load path.** With Propshaft this
|
|
33
|
+
publishes the directory: `assets:precompile` copies every component `.rb`
|
|
34
|
+
and `.html.erb` into `public/assets`, `.manifest.json` maps each logical
|
|
35
|
+
name to its digested filename, and the web server serves the source with
|
|
36
|
+
`HTTP 200` without Rails involved. Senren raises at boot in production if it
|
|
37
|
+
finds this, and warns elsewhere. Sidecar assets belong in their own
|
|
38
|
+
directory:
|
|
39
|
+
```ruby
|
|
40
|
+
# config/initializers/assets.rb
|
|
41
|
+
Rails.application.config.assets.paths << Rails.root.join("app/components/assets")
|
|
42
|
+
# NOT app/components
|
|
43
|
+
```
|
|
44
|
+
|
|
30
45
|
## File ownership
|
|
31
46
|
|
|
32
47
|
| Path | Owned by |
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'yaml'
|
|
4
|
+
require 'senren/rails/marker_block'
|
|
5
|
+
require 'senren/rails/safe_write'
|
|
4
6
|
|
|
5
7
|
module Senren
|
|
6
8
|
module Rails
|
|
@@ -21,6 +23,7 @@ module Senren
|
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
def sync!
|
|
26
|
+
assert_distinct_adapters!
|
|
24
27
|
paths.ensure_agent_dirs!
|
|
25
28
|
files = []
|
|
26
29
|
files << write_full_file(paths.agent_rules_file, render_source_rules)
|
|
@@ -31,8 +34,46 @@ module Senren
|
|
|
31
34
|
files
|
|
32
35
|
end
|
|
33
36
|
|
|
37
|
+
# Each adapter gets different content, so two of them resolving to the
|
|
38
|
+
# same file means the last write silently wins.
|
|
39
|
+
#
|
|
40
|
+
# `ln -s AGENTS.md CLAUDE.md` is a normal way to keep one set of agent
|
|
41
|
+
# instructions, and now that in-repo symlinks are allowed it reaches here
|
|
42
|
+
# rather than being refused as an escape.
|
|
43
|
+
#
|
|
44
|
+
# Public so ComponentInstaller can run it as a preflight. The first
|
|
45
|
+
# version of this check lived inside sync!, which runs after the copier,
|
|
46
|
+
# so `senren:add` failed with components already on disk and the ledger
|
|
47
|
+
# already written.
|
|
48
|
+
def assert_distinct_adapters!
|
|
49
|
+
collisions = adapter_targets.group_by { |path, _| SafeWrite.real_target(path) }
|
|
50
|
+
.select { |_, group| group.size > 1 }
|
|
51
|
+
return if collisions.empty?
|
|
52
|
+
|
|
53
|
+
raise ArgumentError, collision_message(collisions)
|
|
54
|
+
end
|
|
55
|
+
|
|
34
56
|
private
|
|
35
57
|
|
|
58
|
+
def adapter_targets
|
|
59
|
+
{
|
|
60
|
+
paths.codex_agents_md => 'AGENTS.md',
|
|
61
|
+
paths.claude_md => 'CLAUDE.md',
|
|
62
|
+
paths.copilot_instructions => '.github/copilot-instructions.md',
|
|
63
|
+
paths.cursor_rule_file => '.cursor/rules/senren.mdc'
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def collision_message(collisions)
|
|
68
|
+
detail = collisions.map do |target, group|
|
|
69
|
+
"#{group.map(&:last).join(' and ')} both resolve to #{target}"
|
|
70
|
+
end.join('; ')
|
|
71
|
+
|
|
72
|
+
'Senren writes different instructions to each agent adapter, so these cannot share a file: ' \
|
|
73
|
+
"#{detail}. Replace the link with a real file, or have one adapter reference the other " \
|
|
74
|
+
'by path instead of linking to it.'
|
|
75
|
+
end
|
|
76
|
+
|
|
36
77
|
def installed_names
|
|
37
78
|
path = paths.installed_components
|
|
38
79
|
return [] unless path.exist?
|
|
@@ -57,8 +98,14 @@ module Senren
|
|
|
57
98
|
- Use ViewComponent for reusable UI.
|
|
58
99
|
- Use Turbo for server state.
|
|
59
100
|
- Use Stimulus only for local behavior.
|
|
60
|
-
-
|
|
101
|
+
- Keep interactivity in Stimulus. These components render on the server,
|
|
102
|
+
so a client-side framework alongside them means two systems own the
|
|
103
|
+
same state.
|
|
61
104
|
- Use semantic Tailwind tokens; do not hard-code color families.
|
|
105
|
+
- Never add `app/components` to `config.assets.paths`. Propshaft
|
|
106
|
+
publishes every file under an asset path, so component `.rb` and
|
|
107
|
+
`.html.erb` source is precompiled into `public/assets` and served
|
|
108
|
+
over HTTP. Put sidecar assets in `app/components/assets` instead.
|
|
62
109
|
|
|
63
110
|
## Important Files
|
|
64
111
|
|
|
@@ -82,7 +129,7 @@ module Senren
|
|
|
82
129
|
- Prefer Senren components before custom HTML.
|
|
83
130
|
- Keep reusable UI in ViewComponent.
|
|
84
131
|
- Use Turbo for server state, Stimulus for local behavior.
|
|
85
|
-
-
|
|
132
|
+
- Keep interactivity in Stimulus rather than a client-side framework.
|
|
86
133
|
- Use semantic Tailwind tokens.
|
|
87
134
|
MD
|
|
88
135
|
end
|
|
@@ -106,7 +153,7 @@ module Senren
|
|
|
106
153
|
- Prefer Senren components before custom HTML.
|
|
107
154
|
- Reusable UI must use ViewComponent.
|
|
108
155
|
- Turbo handles server state; Stimulus handles local behavior.
|
|
109
|
-
-
|
|
156
|
+
- Keep interactivity in Stimulus rather than a client-side framework.
|
|
110
157
|
- Use semantic Tailwind tokens.
|
|
111
158
|
MD
|
|
112
159
|
end
|
|
@@ -120,7 +167,7 @@ module Senren
|
|
|
120
167
|
- Prefer Senren components before custom HTML.
|
|
121
168
|
- Reusable UI uses ViewComponent.
|
|
122
169
|
- Turbo for server state, Stimulus for local behavior.
|
|
123
|
-
-
|
|
170
|
+
- Keep interactivity in Stimulus rather than a client-side framework.
|
|
124
171
|
- Use semantic Tailwind tokens.
|
|
125
172
|
MD
|
|
126
173
|
end
|
|
@@ -147,28 +194,24 @@ module Senren
|
|
|
147
194
|
|
|
148
195
|
def write_adapter_file(path, generated, prefix: '')
|
|
149
196
|
existing = path.exist? ? path.read : prefix.to_s
|
|
150
|
-
updated = inject(existing, generated)
|
|
197
|
+
updated = inject(existing, generated, label: path.to_s)
|
|
151
198
|
atomic_write(path, updated)
|
|
152
199
|
path
|
|
153
200
|
end
|
|
154
201
|
|
|
155
|
-
def inject(existing, generated)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
"#{before}#{START_MARKER}\n\n#{generated.rstrip}\n\n#{END_MARKER}#{after}"
|
|
161
|
-
else
|
|
162
|
-
body = existing.rstrip
|
|
163
|
-
prefix = body.empty? ? '' : "#{body}\n\n"
|
|
164
|
-
"#{prefix}#{START_MARKER}\n\n#{generated.rstrip}\n\n#{END_MARKER}\n"
|
|
165
|
-
end
|
|
202
|
+
def inject(existing, generated, label: nil)
|
|
203
|
+
MarkerBlock.inject(
|
|
204
|
+
existing, generated,
|
|
205
|
+
start_marker: START_MARKER, end_marker: END_MARKER, label: label
|
|
206
|
+
)
|
|
166
207
|
end
|
|
167
208
|
|
|
209
|
+
# write_adapter_file reads its destination before rewriting it, so a
|
|
210
|
+
# symlinked .senren, .github or .cursor/rules did not merely redirect the
|
|
211
|
+
# write — it pulled outside content in and wrote it back out. SafeWrite
|
|
212
|
+
# refuses on the real path before either half of that happens.
|
|
168
213
|
def atomic_write(path, content)
|
|
169
|
-
|
|
170
|
-
File.write(tmp, content)
|
|
171
|
-
File.rename(tmp, path)
|
|
214
|
+
SafeWrite.write!(path, content, paths.root, path.to_s)
|
|
172
215
|
end
|
|
173
216
|
end
|
|
174
217
|
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Senren
|
|
4
|
+
module Rails
|
|
5
|
+
# Refuses to let component source be served as a static asset.
|
|
6
|
+
#
|
|
7
|
+
# ViewComponent documents putting sidecar assets next to components, and the
|
|
8
|
+
# usual way to reach them is:
|
|
9
|
+
#
|
|
10
|
+
# config.assets.paths << Rails.root.join("app/components")
|
|
11
|
+
#
|
|
12
|
+
# With Propshaft that one line makes every file under app/components a
|
|
13
|
+
# servable asset, `.rb` and `.html.erb` included. Verified against Propshaft
|
|
14
|
+
# 1.3.2: 129 component source files resolved, `assets:precompile` copied all
|
|
15
|
+
# of them into public/assets/, and public/assets/.manifest.json listed each
|
|
16
|
+
# logical path next to its digested filename — so the digest is not even an
|
|
17
|
+
# obstacle. In production the web server hands them out with Rails never
|
|
18
|
+
# involved.
|
|
19
|
+
#
|
|
20
|
+
# Development is a different risk calculation, so there it warns. Production
|
|
21
|
+
# raises: a boot failure is recoverable, published source is not.
|
|
22
|
+
module AssetPathGuard
|
|
23
|
+
SOURCE_EXTENSIONS = %w[.rb .erb].freeze
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# `production:` is injected rather than read from ::Rails so the guard can
|
|
28
|
+
# be unit tested without booting Rails, which is how the rest of this
|
|
29
|
+
# library's unit suite runs.
|
|
30
|
+
def check!(app, io: $stderr, production: production_env?)
|
|
31
|
+
offenders = offending_paths(app)
|
|
32
|
+
return true if offenders.empty?
|
|
33
|
+
|
|
34
|
+
message = message_for(offenders)
|
|
35
|
+
raise message if production
|
|
36
|
+
|
|
37
|
+
io.puts("[senren] WARNING: #{message}")
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Fails closed. `Rails.env.production?` let a conventional
|
|
42
|
+
# RAILS_ENV=staging deploy print one line of stderr and precompile the
|
|
43
|
+
# source anyway, and the same held for review apps and any custom
|
|
44
|
+
# environment name. `local?` is true only for development and test — the
|
|
45
|
+
# two environments where exposure is acceptable — so everything else is
|
|
46
|
+
# treated as deployed. Available since Rails 7.1, which is the floor.
|
|
47
|
+
def production_env?
|
|
48
|
+
return false unless defined?(::Rails) && ::Rails.respond_to?(:env)
|
|
49
|
+
return !::Rails.env.local? if ::Rails.env.respond_to?(:local?)
|
|
50
|
+
|
|
51
|
+
!%w[development test].include?(::Rails.env.to_s)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# An asset path is only a problem when component source actually sits
|
|
55
|
+
# under it, so an app that keeps sidecar assets in their own directory is
|
|
56
|
+
# left alone.
|
|
57
|
+
def offending_paths(app)
|
|
58
|
+
components = components_dir(app)
|
|
59
|
+
return [] unless components&.directory?
|
|
60
|
+
|
|
61
|
+
asset_paths(app).select { |path| publishes_source?(path, components) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Whether serving this asset path would serve component source.
|
|
65
|
+
#
|
|
66
|
+
# `source_files?` used to be asked once about app/components as a whole,
|
|
67
|
+
# which is the wrong question for a descendant path: with that shape,
|
|
68
|
+
# app/components/assets holding nothing but CSS looked identical to
|
|
69
|
+
# app/components/senren holding every component. Only the overlapping
|
|
70
|
+
# subtree can publish anything, and that subtree is the deeper of the two
|
|
71
|
+
# paths.
|
|
72
|
+
def publishes_source?(asset_path, components)
|
|
73
|
+
asset = Pathname.new(asset_path.to_s).expand_path
|
|
74
|
+
components = components.expand_path
|
|
75
|
+
return false unless overlap?(asset, components)
|
|
76
|
+
|
|
77
|
+
source_files?(asset.to_s.length >= components.to_s.length ? asset : components)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def components_dir(app)
|
|
81
|
+
app.root.join('app/components')
|
|
82
|
+
rescue StandardError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def source_files?(dir)
|
|
87
|
+
SOURCE_EXTENSIONS.any? { |ext| Dir.glob(dir.join("**/*#{ext}")).any? }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def asset_paths(app)
|
|
91
|
+
app.config.respond_to?(:assets) ? Array(app.config.assets.paths) : []
|
|
92
|
+
rescue StandardError
|
|
93
|
+
[]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Two paths overlap when one contains the other, in EITHER direction, and
|
|
97
|
+
# the test has to be separator-aware:
|
|
98
|
+
#
|
|
99
|
+
# app/components is an ancestor -> publishes everything
|
|
100
|
+
# app/components/senren is a descendant -> publishes the components
|
|
101
|
+
# app/comp shares a prefix -> publishes nothing
|
|
102
|
+
#
|
|
103
|
+
# The first version tested only the ancestor direction with a bare
|
|
104
|
+
# start_with?, so it missed the descendant case — which is what a
|
|
105
|
+
# developer writes after reading this module's own remediation text — and
|
|
106
|
+
# it blocked production boots over an unrelated app/comp directory.
|
|
107
|
+
def overlap?(one, other)
|
|
108
|
+
a = "#{one}#{File::SEPARATOR}"
|
|
109
|
+
b = "#{other}#{File::SEPARATOR}"
|
|
110
|
+
|
|
111
|
+
a.start_with?(b) || b.start_with?(a)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def message_for(offenders)
|
|
115
|
+
<<~MESSAGE.strip
|
|
116
|
+
app/components is on the asset load path (#{offenders.join(', ')}).
|
|
117
|
+
|
|
118
|
+
Propshaft serves every file under an asset path, so your component
|
|
119
|
+
.rb and .html.erb source would be published — assets:precompile copies
|
|
120
|
+
them into public/assets and .manifest.json lists them by name.
|
|
121
|
+
|
|
122
|
+
Move sidecar assets into their own directory and add that instead, for
|
|
123
|
+
example app/components/assets rather than app/components.
|
|
124
|
+
MESSAGE
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Senren
|
|
4
|
+
module Rails
|
|
5
|
+
# Ruby appended to a host app's existing BaseComponent when it predates the
|
|
6
|
+
# URL-aware component templates.
|
|
7
|
+
#
|
|
8
|
+
# This duplicates the helpers in
|
|
9
|
+
# lib/generators/senren/install/templates/base_component.rb.tt, because
|
|
10
|
+
# apps installed before those helpers existed never receive the template
|
|
11
|
+
# again. The two definitions are pinned together by
|
|
12
|
+
# test/security/component_url_security_test.rb: if they drift, migrated apps
|
|
13
|
+
# silently keep an older, weaker safe_url.
|
|
14
|
+
module BaseComponentPatch
|
|
15
|
+
# Single-quoted heredoc: the body is emitted verbatim, so the backslash
|
|
16
|
+
# escapes inside safe_url survive into the host app's file.
|
|
17
|
+
URL_HELPERS = <<~'RUBY'
|
|
18
|
+
|
|
19
|
+
# Added by senren:add for compatibility with URL-aware component templates.
|
|
20
|
+
require 'uri'
|
|
21
|
+
|
|
22
|
+
module Senren
|
|
23
|
+
class BaseComponent
|
|
24
|
+
SAFE_URL_PROTOCOLS = %w[http https mailto tel].freeze unless const_defined?(:SAFE_URL_PROTOCOLS)
|
|
25
|
+
SAFE_MEDIA_URL_PROTOCOLS = %w[http https].freeze unless const_defined?(:SAFE_MEDIA_URL_PROTOCOLS)
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def safe_url(value, fallback: '#', protocols: SAFE_URL_PROTOCOLS)
|
|
30
|
+
url = value.to_s.strip
|
|
31
|
+
return fallback if url.empty?
|
|
32
|
+
# Browsers treat "\" as "/" for special schemes and strip TAB/CR/LF
|
|
33
|
+
# before parsing, so "/\evil.example" and "/<TAB>/evil.example" would
|
|
34
|
+
# both slip past a plain "//" check and resolve off-origin.
|
|
35
|
+
return fallback if url.include?('\\')
|
|
36
|
+
return fallback if url.match?(/[[:cntrl:]]/)
|
|
37
|
+
return url if url.start_with?('#')
|
|
38
|
+
# Any leading "//" is protocol-relative regardless of how many slashes
|
|
39
|
+
# follow. Rejecting here rather than relying on URI.parse matters:
|
|
40
|
+
# URI.parse("///evil.example") reports no scheme and no host, so the
|
|
41
|
+
# scheme-less fallback below would otherwise hand back a URL the browser
|
|
42
|
+
# resolves to https://evil.example/.
|
|
43
|
+
return fallback if url.start_with?('//')
|
|
44
|
+
return url if url.start_with?('/')
|
|
45
|
+
|
|
46
|
+
uri = URI.parse(url)
|
|
47
|
+
return url if uri.scheme && Array(protocols).map(&:to_s).include?(uri.scheme.downcase)
|
|
48
|
+
return fallback if uri.host
|
|
49
|
+
return url unless uri.scheme
|
|
50
|
+
|
|
51
|
+
fallback
|
|
52
|
+
rescue URI::InvalidURIError
|
|
53
|
+
fallback
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def safe_media_url(value, fallback: nil)
|
|
57
|
+
safe_url(value, fallback: fallback, protocols: SAFE_MEDIA_URL_PROTOCOLS)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
RUBY
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|