senren-ui 0.1.4 → 0.1.6
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 +49 -2
- data/CONTRIBUTING.md +41 -8
- data/README.md +73 -11
- data/docs/components.md +222 -0
- data/docs/performance_testing.md +34 -0
- data/lib/commands/senren/add/add_command.rb +35 -0
- data/lib/generators/senren/install/install_generator.rb +4 -7
- data/lib/generators/senren/install/templates/base_component.rb.tt +39 -6
- data/lib/generators/senren/install/templates/conventions.md.tt +22 -8
- data/lib/senren/rails/agent_rules_writer.rb +175 -0
- data/lib/senren/rails/component_copier.rb +75 -6
- data/lib/senren/rails/component_installer.rb +47 -0
- data/lib/senren/rails/doctor.rb +26 -13
- data/lib/senren/rails/host_paths.rb +12 -3
- data/lib/senren/rails/installer.rb +4 -2
- data/lib/senren/rails/llms_writer.rb +5 -132
- data/lib/senren/rails/registry.rb +63 -31
- data/lib/senren/rails/skill_writer.rb +1 -1
- data/lib/senren/rails/version.rb +1 -1
- data/lib/senren/rails.rb +2 -0
- data/lib/tasks/senren.rake +26 -21
- data/templates/components/billing_plan_card/billing_plan_card_component.html.erb +1 -1
- data/templates/components/breadcrumb/breadcrumb_component.rb +2 -2
- data/templates/components/button/button_component.html.erb +1 -1
- data/templates/components/carousel/carousel_component.rb +1 -1
- data/templates/components/command/command_component.rb +1 -1
- data/templates/components/dropdown_menu/dropdown_menu_component.rb +10 -7
- data/templates/components/form/form_component.html.erb +8 -1
- data/templates/components/form/form_component.rb +3 -1
- data/templates/components/input/input_component.html.erb +1 -1
- data/templates/components/input/input_component.rb +19 -0
- data/templates/components/label/label_component.html.erb +1 -2
- data/templates/components/label/label_component.rb +12 -2
- data/templates/components/link/link_component.html.erb +1 -1
- data/templates/components/native_select/native_select_component.html.erb +19 -5
- data/templates/components/native_select/native_select_component.rb +17 -5
- data/templates/components/pagination/pagination_component.rb +2 -1
- data/templates/components/sidebar/sidebar_component.rb +2 -2
- data/templates/components/switch/switch_component.html.erb +2 -2
- data/templates/components/top_nav/top_nav_component.rb +2 -2
- data/templates/controllers/rich_text_editor_lite_controller.js +12 -2
- metadata +23 -4
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
require 'uri'
|
|
2
|
+
require 'view_component'
|
|
3
|
+
|
|
1
4
|
module Senren
|
|
2
5
|
# Base class for every Senren ViewComponent.
|
|
3
6
|
#
|
|
@@ -6,13 +9,16 @@ module Senren
|
|
|
6
9
|
# - variant and size resolution against class-level constants
|
|
7
10
|
# - small class merger (no external `tailwind_merge` dependency in v0.1)
|
|
8
11
|
# - a default root-attribute helper that emits `data-senren-component="<name>"`
|
|
9
|
-
class BaseComponent < ViewComponent::Base
|
|
10
|
-
VARIANTS = { default:
|
|
11
|
-
SIZES = { md:
|
|
12
|
+
class BaseComponent < ::ViewComponent::Base
|
|
13
|
+
VARIANTS = { default: '' }.freeze
|
|
14
|
+
SIZES = { md: '' }.freeze
|
|
15
|
+
SAFE_URL_PROTOCOLS = %w[http https mailto tel].freeze
|
|
16
|
+
SAFE_MEDIA_URL_PROTOCOLS = %w[http https].freeze
|
|
12
17
|
|
|
13
18
|
attr_reader :class_name, :variant, :size, :html_attrs
|
|
14
19
|
|
|
15
20
|
def initialize(variant: :default, size: :md, class_name: nil, **html_attrs)
|
|
21
|
+
super()
|
|
16
22
|
@variant = resolve!(variant, self.class::VARIANTS, :variant)
|
|
17
23
|
@size = resolve!(size, self.class::SIZES, :size)
|
|
18
24
|
@class_name = class_name
|
|
@@ -22,24 +28,51 @@ module Senren
|
|
|
22
28
|
# Compose final root attributes; subclasses pass their base classes.
|
|
23
29
|
def root_attrs(*classes, **extra)
|
|
24
30
|
data = (extra.delete(:data) || {}).merge(senren_component: senren_component_name)
|
|
25
|
-
tag_class = merge_classes(
|
|
31
|
+
tag_class = merge_classes(
|
|
32
|
+
classes,
|
|
33
|
+
self.class::VARIANTS[@variant],
|
|
34
|
+
self.class::SIZES[@size],
|
|
35
|
+
@class_name,
|
|
36
|
+
extra.delete(:class)
|
|
37
|
+
)
|
|
26
38
|
{ class: tag_class, data: data, **html_attrs, **extra }
|
|
27
39
|
end
|
|
28
40
|
|
|
29
41
|
def senren_component_name
|
|
30
|
-
self.class.name.to_s.sub(/^Senren::/,
|
|
42
|
+
self.class.name.to_s.sub(/^Senren::/, '').sub(/Component$/, '').gsub(/([a-z])([A-Z])/, '\1_\2').downcase
|
|
31
43
|
end
|
|
32
44
|
|
|
33
45
|
private
|
|
34
46
|
|
|
47
|
+
def safe_url(value, fallback: '#', protocols: SAFE_URL_PROTOCOLS)
|
|
48
|
+
url = value.to_s.strip
|
|
49
|
+
return fallback if url.empty?
|
|
50
|
+
return url if url.start_with?('#')
|
|
51
|
+
return url if url.start_with?('/') && !url.start_with?('//')
|
|
52
|
+
|
|
53
|
+
uri = URI.parse(url)
|
|
54
|
+
return url if uri.scheme && Array(protocols).map(&:to_s).include?(uri.scheme.downcase)
|
|
55
|
+
return fallback if uri.host
|
|
56
|
+
return url unless uri.scheme
|
|
57
|
+
|
|
58
|
+
fallback
|
|
59
|
+
rescue URI::InvalidURIError
|
|
60
|
+
fallback
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def safe_media_url(value, fallback: nil)
|
|
64
|
+
safe_url(value, fallback: fallback, protocols: SAFE_MEDIA_URL_PROTOCOLS)
|
|
65
|
+
end
|
|
66
|
+
|
|
35
67
|
def resolve!(value, table, label)
|
|
36
68
|
key = value.to_sym
|
|
37
69
|
return key if table.key?(key)
|
|
70
|
+
|
|
38
71
|
raise ArgumentError, "Unknown #{label}: #{value.inspect}. Allowed: #{table.keys.join(', ')}"
|
|
39
72
|
end
|
|
40
73
|
|
|
41
74
|
def merge_classes(*sources)
|
|
42
|
-
sources.flatten.map { |s| s.to_s.strip }.reject(&:empty?).join(
|
|
75
|
+
sources.flatten.map { |s| s.to_s.strip }.reject(&:empty?).join(' ')
|
|
43
76
|
end
|
|
44
77
|
end
|
|
45
78
|
end
|
|
@@ -20,11 +20,11 @@ and obey it strictly.
|
|
|
20
20
|
block to `.new`, **not** to `render`, producing an empty component.
|
|
21
21
|
Use either of these forms instead:
|
|
22
22
|
```erb
|
|
23
|
-
|
|
23
|
+
<%%= render(Senren::ButtonComponent.new(variant: :primary)) { "Save" } %>
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
<%%= render Senren::ButtonComponent.new(variant: :primary) do %>
|
|
26
26
|
Save
|
|
27
|
-
|
|
27
|
+
<%% end %>
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
## File ownership
|
|
@@ -38,15 +38,22 @@ and obey it strictly.
|
|
|
38
38
|
| `.senren/registry.yml` | Generator (mirror of gem registry) |
|
|
39
39
|
| `.senren/installed_components.yml` | Generator (ledger) |
|
|
40
40
|
| `.senren/conventions.md` | This file - safe to edit |
|
|
41
|
-
|
|
|
42
|
-
| `
|
|
41
|
+
| `.senren/agent-rules.md` | Generator (do not edit) |
|
|
42
|
+
| `AGENTS.md` | Generated region only (between markers) |
|
|
43
|
+
| `CLAUDE.md` | Generated region only (between markers) |
|
|
44
|
+
| `.github/copilot-instructions.md` | Generated region only (between markers) |
|
|
45
|
+
| `.cursor/rules/senren.mdc` | Generated region only (between markers) |
|
|
43
46
|
|
|
44
47
|
## Adding a component
|
|
45
48
|
|
|
46
49
|
```bash
|
|
47
|
-
bin/rails senren:add
|
|
48
|
-
bin/rails senren:add
|
|
49
|
-
bin/rails senren:add
|
|
50
|
+
bin/rails senren:add dialog
|
|
51
|
+
bin/rails senren:add button card badge
|
|
52
|
+
bin/rails senren:add dialog --no-client # override registry default
|
|
53
|
+
bundle exec rails senren:add button --client # equivalent alternate entry point
|
|
54
|
+
|
|
55
|
+
# Backward-compatible legacy task syntax:
|
|
56
|
+
bin/rails 'senren:add[button,card,badge]'
|
|
50
57
|
```
|
|
51
58
|
|
|
52
59
|
After install you can edit any file under `app/components/senren/` directly.
|
|
@@ -64,3 +71,10 @@ Senren never overwrites those files unless you pass `--force`.
|
|
|
64
71
|
|
|
65
72
|
You can write app-specific notes outside those markers. They are preserved
|
|
66
73
|
across `bin/rails senren:skill:sync`.
|
|
74
|
+
|
|
75
|
+
## Agent instruction maintenance
|
|
76
|
+
|
|
77
|
+
Senren writes a shared source file at `.senren/agent-rules.md` and syncs
|
|
78
|
+
adapter files for Codex/Cursor/Claude/Copilot. For adapter files, only the
|
|
79
|
+
generated block between markers is managed by Senren; your custom notes
|
|
80
|
+
outside markers are preserved.
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Senren
|
|
6
|
+
module Rails
|
|
7
|
+
# Generates a single source-of-truth rules file plus adapter files for
|
|
8
|
+
# Copilot, Cursor, Claude, and Codex.
|
|
9
|
+
#
|
|
10
|
+
# Adapter files are marker-managed to avoid overwriting existing project
|
|
11
|
+
# instructions outside Senren's generated block.
|
|
12
|
+
class AgentRulesWriter
|
|
13
|
+
START_MARKER = '<!-- senren:agent:start -->'
|
|
14
|
+
END_MARKER = '<!-- senren:agent:end -->'
|
|
15
|
+
|
|
16
|
+
attr_reader :registry, :paths
|
|
17
|
+
|
|
18
|
+
def initialize(registry: Registry.load!, paths: HostPaths.new)
|
|
19
|
+
@registry = registry
|
|
20
|
+
@paths = paths
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def sync!
|
|
24
|
+
paths.ensure_agent_dirs!
|
|
25
|
+
files = []
|
|
26
|
+
files << write_full_file(paths.agent_rules_file, render_source_rules)
|
|
27
|
+
files << write_adapter_file(paths.codex_agents_md, render_codex_adapter)
|
|
28
|
+
files << write_adapter_file(paths.claude_md, render_claude_adapter)
|
|
29
|
+
files << write_adapter_file(paths.copilot_instructions, render_copilot_adapter)
|
|
30
|
+
files << write_adapter_file(paths.cursor_rule_file, render_cursor_adapter, prefix: cursor_frontmatter)
|
|
31
|
+
files
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def installed_names
|
|
37
|
+
path = paths.installed_components
|
|
38
|
+
return [] unless path.exist?
|
|
39
|
+
|
|
40
|
+
ledger = YAML.safe_load_file(path) || {}
|
|
41
|
+
Array(ledger['installed']).filter_map { |entry| registry.find(entry['name']) }.map(&:name).sort
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def render_source_rules
|
|
45
|
+
names = installed_names
|
|
46
|
+
<<~MD
|
|
47
|
+
# Senren Agent Rules
|
|
48
|
+
|
|
49
|
+
Generated by senren-ui. Do not edit by hand.
|
|
50
|
+
Run `bin/rails senren:agents:sync` to regenerate.
|
|
51
|
+
|
|
52
|
+
This file is the Senren source of truth for AI coding agents in this app.
|
|
53
|
+
|
|
54
|
+
## Hard Rules
|
|
55
|
+
|
|
56
|
+
- Use Senren components before writing custom HTML.
|
|
57
|
+
- Use ViewComponent for reusable UI.
|
|
58
|
+
- Use Turbo for server state.
|
|
59
|
+
- Use Stimulus only for local behavior.
|
|
60
|
+
- Do not introduce React, Vue, Alpine, or external state frameworks.
|
|
61
|
+
- Use semantic Tailwind tokens; do not hard-code color families.
|
|
62
|
+
|
|
63
|
+
## Important Files
|
|
64
|
+
|
|
65
|
+
- `.senren/skill.md` - centralized component guidance
|
|
66
|
+
- `.senren/conventions.md` - project conventions
|
|
67
|
+
- `.senren/registry.yml` - component registry mirror
|
|
68
|
+
- `.senren/installed_components.yml` - local install ledger
|
|
69
|
+
|
|
70
|
+
## Installed Components (#{names.size})
|
|
71
|
+
|
|
72
|
+
#{format_names(names)}
|
|
73
|
+
MD
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def render_codex_adapter
|
|
77
|
+
<<~MD
|
|
78
|
+
## Senren UI
|
|
79
|
+
|
|
80
|
+
Follow `.senren/agent-rules.md` as the source of truth.
|
|
81
|
+
|
|
82
|
+
- Prefer Senren components before custom HTML.
|
|
83
|
+
- Keep reusable UI in ViewComponent.
|
|
84
|
+
- Use Turbo for server state, Stimulus for local behavior.
|
|
85
|
+
- Do not add React, Vue, Alpine, or external state frameworks.
|
|
86
|
+
- Use semantic Tailwind tokens.
|
|
87
|
+
MD
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def render_claude_adapter
|
|
91
|
+
<<~MD
|
|
92
|
+
## Senren UI
|
|
93
|
+
|
|
94
|
+
@.senren/agent-rules.md
|
|
95
|
+
|
|
96
|
+
Apply Senren conventions and component-first rules from the imported file.
|
|
97
|
+
MD
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def render_copilot_adapter
|
|
101
|
+
<<~MD
|
|
102
|
+
## Senren UI
|
|
103
|
+
|
|
104
|
+
Use `.senren/agent-rules.md` as the source of truth for this repository.
|
|
105
|
+
|
|
106
|
+
- Prefer Senren components before custom HTML.
|
|
107
|
+
- Reusable UI must use ViewComponent.
|
|
108
|
+
- Turbo handles server state; Stimulus handles local behavior.
|
|
109
|
+
- Do not introduce React, Vue, Alpine, or external state frameworks.
|
|
110
|
+
- Use semantic Tailwind tokens.
|
|
111
|
+
MD
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def render_cursor_adapter
|
|
115
|
+
<<~MD
|
|
116
|
+
## Senren UI
|
|
117
|
+
|
|
118
|
+
Follow `.senren/agent-rules.md` as the source of truth.
|
|
119
|
+
|
|
120
|
+
- Prefer Senren components before custom HTML.
|
|
121
|
+
- Reusable UI uses ViewComponent.
|
|
122
|
+
- Turbo for server state, Stimulus for local behavior.
|
|
123
|
+
- No React/Vue/Alpine/external state frameworks.
|
|
124
|
+
- Use semantic Tailwind tokens.
|
|
125
|
+
MD
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def cursor_frontmatter
|
|
129
|
+
<<~MDC
|
|
130
|
+
---
|
|
131
|
+
description: Senren UI repository conventions
|
|
132
|
+
alwaysApply: true
|
|
133
|
+
---
|
|
134
|
+
MDC
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def format_names(names)
|
|
138
|
+
return '_No components installed yet._' if names.empty?
|
|
139
|
+
|
|
140
|
+
names.map { |name| "- #{name}" }.join("\n")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def write_full_file(path, content)
|
|
144
|
+
atomic_write(path, content)
|
|
145
|
+
path
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def write_adapter_file(path, generated, prefix: '')
|
|
149
|
+
existing = path.exist? ? path.read : prefix.to_s
|
|
150
|
+
updated = inject(existing, generated)
|
|
151
|
+
atomic_write(path, updated)
|
|
152
|
+
path
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def inject(existing, generated)
|
|
156
|
+
if existing.include?(START_MARKER) && existing.include?(END_MARKER)
|
|
157
|
+
before = existing.split(START_MARKER, 2).first
|
|
158
|
+
tail = existing.split(START_MARKER, 2).last
|
|
159
|
+
after = tail.split(END_MARKER, 2).last
|
|
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
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def atomic_write(path, content)
|
|
169
|
+
tmp = "#{path}.tmp"
|
|
170
|
+
File.write(tmp, content)
|
|
171
|
+
File.rename(tmp, path)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -9,6 +9,47 @@ module Senren
|
|
|
9
9
|
# Copies component files from the gem's templates/ tree into the host
|
|
10
10
|
# Rails app, and updates .senren/installed_components.yml.
|
|
11
11
|
class ComponentCopier
|
|
12
|
+
class MissingTemplate < StandardError; end
|
|
13
|
+
|
|
14
|
+
INSTALL_GENERATOR_TEMPLATES = File.expand_path(
|
|
15
|
+
'../../generators/senren/install/templates', __dir__
|
|
16
|
+
).freeze
|
|
17
|
+
BASE_COMPONENT_TEMPLATE = File.join(INSTALL_GENERATOR_TEMPLATES, 'base_component.rb.tt').freeze
|
|
18
|
+
BASE_URL_HELPER_PATCH = <<~RUBY
|
|
19
|
+
|
|
20
|
+
# Added by senren:add for compatibility with URL-aware component templates.
|
|
21
|
+
require 'uri'
|
|
22
|
+
|
|
23
|
+
module Senren
|
|
24
|
+
class BaseComponent
|
|
25
|
+
SAFE_URL_PROTOCOLS = %w[http https mailto tel].freeze unless const_defined?(:SAFE_URL_PROTOCOLS)
|
|
26
|
+
SAFE_MEDIA_URL_PROTOCOLS = %w[http https].freeze unless const_defined?(:SAFE_MEDIA_URL_PROTOCOLS)
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def safe_url(value, fallback: '#', protocols: SAFE_URL_PROTOCOLS)
|
|
31
|
+
url = value.to_s.strip
|
|
32
|
+
return fallback if url.empty?
|
|
33
|
+
return url if url.start_with?('#')
|
|
34
|
+
return url if url.start_with?('/') && !url.start_with?('//')
|
|
35
|
+
|
|
36
|
+
uri = URI.parse(url)
|
|
37
|
+
return url if uri.scheme && Array(protocols).map(&:to_s).include?(uri.scheme.downcase)
|
|
38
|
+
return fallback if uri.host
|
|
39
|
+
return url unless uri.scheme
|
|
40
|
+
|
|
41
|
+
fallback
|
|
42
|
+
rescue URI::InvalidURIError
|
|
43
|
+
fallback
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def safe_media_url(value, fallback: nil)
|
|
47
|
+
safe_url(value, fallback: fallback, protocols: SAFE_MEDIA_URL_PROTOCOLS)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
RUBY
|
|
52
|
+
|
|
12
53
|
attr_reader :registry, :paths, :stdout
|
|
13
54
|
|
|
14
55
|
def initialize(registry: Registry.load!, paths: HostPaths.new, stdout: $stdout)
|
|
@@ -22,6 +63,7 @@ module Senren
|
|
|
22
63
|
def install(component_names, client_override: nil, force: false)
|
|
23
64
|
wanted = registry.dependencies(*component_names)
|
|
24
65
|
paths.ensure_dirs!
|
|
66
|
+
ensure_base_component_url_helpers!
|
|
25
67
|
|
|
26
68
|
wanted.each do |name|
|
|
27
69
|
comp = registry.fetch(name)
|
|
@@ -34,6 +76,24 @@ module Senren
|
|
|
34
76
|
|
|
35
77
|
private
|
|
36
78
|
|
|
79
|
+
def ensure_base_component_url_helpers!
|
|
80
|
+
if paths.base_component_path.exist?
|
|
81
|
+
return if base_component_has_url_helpers?
|
|
82
|
+
|
|
83
|
+
File.open(paths.base_component_path, 'a') { |file| file.write(BASE_URL_HELPER_PATCH) }
|
|
84
|
+
stdout.puts " update #{paths.base_component_path} (url helpers)"
|
|
85
|
+
return
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
copy_file(BASE_COMPONENT_TEMPLATE, paths.base_component_path, force: false, label: 'base_component.rb')
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def base_component_has_url_helpers?
|
|
92
|
+
source = paths.base_component_path.read
|
|
93
|
+
|
|
94
|
+
source.include?('def safe_url') && source.include?('def safe_media_url')
|
|
95
|
+
end
|
|
96
|
+
|
|
37
97
|
def install_component(comp, client_override:, force:)
|
|
38
98
|
effective_client = effective_client_for(comp, client_override)
|
|
39
99
|
|
|
@@ -59,20 +119,29 @@ module Senren
|
|
|
59
119
|
# app/components/senren/<name>_component.html.erb -> templates/components/<name>/<name>_component.html.erb
|
|
60
120
|
# app/javascript/controllers/senren/<name>_controller.js -> templates/controllers/<name>_controller.js
|
|
61
121
|
base = File.basename(relative)
|
|
62
|
-
if
|
|
122
|
+
if component_source_path?(comp, relative)
|
|
63
123
|
File.join(Senren::Rails.templates_root, 'components', comp.name, base)
|
|
64
|
-
elsif
|
|
124
|
+
elsif controller_source_path?(comp, relative)
|
|
65
125
|
File.join(Senren::Rails.templates_root, 'controllers', base)
|
|
66
126
|
else
|
|
67
127
|
raise "ComponentCopier: do not know how to map #{relative.inspect}"
|
|
68
128
|
end
|
|
69
129
|
end
|
|
70
130
|
|
|
131
|
+
def component_source_path?(comp, relative)
|
|
132
|
+
[
|
|
133
|
+
"app/components/senren/#{comp.name}_component.rb",
|
|
134
|
+
"app/components/senren/#{comp.name}_component.html.erb"
|
|
135
|
+
].include?(relative)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def controller_source_path?(comp, relative)
|
|
139
|
+
relative == "app/javascript/controllers/senren/#{comp.name}_controller.js"
|
|
140
|
+
end
|
|
141
|
+
|
|
71
142
|
def copy_file(src, dest, force:, label:)
|
|
72
|
-
unless File.exist?(src)
|
|
73
|
-
|
|
74
|
-
return
|
|
75
|
-
end
|
|
143
|
+
raise MissingTemplate, "Missing component template: #{src} (#{label})" unless File.exist?(src)
|
|
144
|
+
|
|
76
145
|
if File.exist?(dest) && !force
|
|
77
146
|
stdout.puts " skip #{dest} (already exists)"
|
|
78
147
|
return
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'senren/rails/agent_rules_writer'
|
|
4
|
+
require 'senren/rails/component_copier'
|
|
5
|
+
require 'senren/rails/host_paths'
|
|
6
|
+
require 'senren/rails/registry'
|
|
7
|
+
require 'senren/rails/skill_writer'
|
|
8
|
+
|
|
9
|
+
module Senren
|
|
10
|
+
module Rails
|
|
11
|
+
# Installs one or more registered Senren components into the host app and
|
|
12
|
+
# refreshes the generated guidance files afterward.
|
|
13
|
+
class ComponentInstaller
|
|
14
|
+
USAGE = 'Usage: bin/rails senren:add NAME [NAME...] [--client | --no-client]'
|
|
15
|
+
|
|
16
|
+
attr_reader :registry, :paths, :stdout
|
|
17
|
+
|
|
18
|
+
def self.normalize_names(names)
|
|
19
|
+
Array(names)
|
|
20
|
+
.flatten
|
|
21
|
+
.flat_map { |entry| entry.to_s.split(/[,\s]+/) }
|
|
22
|
+
.reject { |entry| entry.empty? || entry.start_with?('-') }
|
|
23
|
+
.uniq
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(registry: Registry.load!, paths: HostPaths.new, stdout: $stdout)
|
|
27
|
+
@registry = registry
|
|
28
|
+
@paths = paths
|
|
29
|
+
@stdout = stdout
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def install(names:, client_override: nil, force: false)
|
|
33
|
+
normalized_names = self.class.normalize_names(names)
|
|
34
|
+
raise ArgumentError, USAGE if normalized_names.empty?
|
|
35
|
+
|
|
36
|
+
installed = ComponentCopier.new(registry: registry, paths: paths, stdout: stdout)
|
|
37
|
+
.install(normalized_names, client_override: client_override, force: force)
|
|
38
|
+
|
|
39
|
+
SkillWriter.new(registry: registry, paths: paths).sync!
|
|
40
|
+
AgentRulesWriter.new(registry: registry, paths: paths).sync!
|
|
41
|
+
|
|
42
|
+
stdout.puts "Installed: #{installed.join(', ')}"
|
|
43
|
+
installed
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/senren/rails/doctor.rb
CHANGED
|
@@ -17,19 +17,7 @@ module Senren
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def run!
|
|
20
|
-
results =
|
|
21
|
-
results << check('ViewComponent gem available') { defined?(::ViewComponent) }
|
|
22
|
-
results << check('TailwindCSS stylesheet present') { paths.stylesheet_path.exist? }
|
|
23
|
-
results << check('Stimulus directory present') { paths.stimulus_dir.directory? }
|
|
24
|
-
results << check('Turbo gem available') { defined?(::Turbo) || gem_loadable?('turbo-rails') }
|
|
25
|
-
results << check('.senren directory exists') { paths.senren_dir.directory? }
|
|
26
|
-
results << check('.senren/skill.md exists') { paths.skill_file.file? }
|
|
27
|
-
results << check('.senren/registry.yml exists') { paths.registry_mirror.file? }
|
|
28
|
-
results << check('.senren/installed_components.yml exists') { paths.installed_components.file? }
|
|
29
|
-
results << check('public/llms.txt exists') { paths.llms_short.file? }
|
|
30
|
-
results << check('public/llms-full.txt exists') { paths.llms_full.file? }
|
|
31
|
-
results << check('app/components/senren exists') { paths.components_dir.directory? }
|
|
32
|
-
results << check('app/javascript/controllers/senren exists') { paths.stimulus_dir.directory? }
|
|
20
|
+
results = runtime_checks + installation_checks
|
|
33
21
|
installed = installed_count
|
|
34
22
|
results << Result.new("#{installed} component(s) installed", installed >= 0, nil)
|
|
35
23
|
|
|
@@ -57,6 +45,31 @@ module Senren
|
|
|
57
45
|
false
|
|
58
46
|
end
|
|
59
47
|
|
|
48
|
+
def runtime_checks
|
|
49
|
+
[
|
|
50
|
+
check('ViewComponent gem available') { defined?(::ViewComponent) },
|
|
51
|
+
check('TailwindCSS stylesheet present') { paths.stylesheet_path.exist? },
|
|
52
|
+
check('Stimulus directory present') { paths.stimulus_dir.directory? },
|
|
53
|
+
check('Turbo gem available') { defined?(::Turbo) || gem_loadable?('turbo-rails') }
|
|
54
|
+
]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def installation_checks
|
|
58
|
+
[
|
|
59
|
+
check('.senren directory exists') { paths.senren_dir.directory? },
|
|
60
|
+
check('.senren/skill.md exists') { paths.skill_file.file? },
|
|
61
|
+
check('.senren/registry.yml exists') { paths.registry_mirror.file? },
|
|
62
|
+
check('.senren/installed_components.yml exists') { paths.installed_components.file? },
|
|
63
|
+
check('.senren/agent-rules.md exists') { paths.agent_rules_file.file? },
|
|
64
|
+
check('AGENTS.md exists') { paths.codex_agents_md.file? },
|
|
65
|
+
check('CLAUDE.md exists') { paths.claude_md.file? },
|
|
66
|
+
check('.github/copilot-instructions.md exists') { paths.copilot_instructions.file? },
|
|
67
|
+
check('.cursor/rules/senren.mdc exists') { paths.cursor_rule_file.file? },
|
|
68
|
+
check('app/components/senren exists') { paths.components_dir.directory? },
|
|
69
|
+
check('app/javascript/controllers/senren exists') { paths.stimulus_dir.directory? }
|
|
70
|
+
]
|
|
71
|
+
end
|
|
72
|
+
|
|
60
73
|
def installed_count
|
|
61
74
|
return 0 unless paths.installed_components.file?
|
|
62
75
|
|
|
@@ -16,6 +16,7 @@ module Senren
|
|
|
16
16
|
def registry_mirror = senren_dir.join('registry.yml')
|
|
17
17
|
def installed_components = senren_dir.join('installed_components.yml')
|
|
18
18
|
def conventions_file = senren_dir.join('conventions.md')
|
|
19
|
+
def agent_rules_file = senren_dir.join('agent-rules.md')
|
|
19
20
|
|
|
20
21
|
def components_dir = root.join('app', 'components', 'senren')
|
|
21
22
|
def base_component_path = components_dir.join('base_component.rb')
|
|
@@ -24,12 +25,20 @@ module Senren
|
|
|
24
25
|
|
|
25
26
|
def stimulus_dir = root.join('app', 'javascript', 'controllers', 'senren')
|
|
26
27
|
|
|
27
|
-
def
|
|
28
|
-
def
|
|
28
|
+
def github_dir = root.join('.github')
|
|
29
|
+
def copilot_instructions = github_dir.join('copilot-instructions.md')
|
|
30
|
+
def cursor_rules_dir = root.join('.cursor', 'rules')
|
|
31
|
+
def cursor_rule_file = cursor_rules_dir.join('senren.mdc')
|
|
32
|
+
def claude_md = root.join('CLAUDE.md')
|
|
33
|
+
def codex_agents_md = root.join('AGENTS.md')
|
|
29
34
|
|
|
30
35
|
def ensure_dirs!
|
|
31
36
|
[senren_dir, components_dir, stimulus_dir,
|
|
32
|
-
stylesheet_path.dirname,
|
|
37
|
+
stylesheet_path.dirname, github_dir, cursor_rules_dir].each(&:mkpath)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def ensure_agent_dirs!
|
|
41
|
+
[senren_dir, github_dir, cursor_rules_dir].each(&:mkpath)
|
|
33
42
|
end
|
|
34
43
|
end
|
|
35
44
|
end
|
|
@@ -5,7 +5,7 @@ require 'fileutils'
|
|
|
5
5
|
module Senren
|
|
6
6
|
module Rails
|
|
7
7
|
# Idempotent installer that lays down the .senren directory, base
|
|
8
|
-
# component, stylesheet, and
|
|
8
|
+
# component, stylesheet, and agent instruction files. Reused by the install
|
|
9
9
|
# generator and by ad-hoc rake task entry points.
|
|
10
10
|
class Installer
|
|
11
11
|
attr_reader :paths, :stdout
|
|
@@ -20,7 +20,7 @@ module Senren
|
|
|
20
20
|
install_static_files(force: force)
|
|
21
21
|
mirror_registry
|
|
22
22
|
SkillWriter.new(paths: paths).sync!
|
|
23
|
-
|
|
23
|
+
AgentRulesWriter.new(paths: paths).sync!
|
|
24
24
|
print_next_steps
|
|
25
25
|
true
|
|
26
26
|
end
|
|
@@ -73,6 +73,8 @@ module Senren
|
|
|
73
73
|
|
|
74
74
|
bin/rails senren:add button card badge alert
|
|
75
75
|
bin/rails senren:add dialog dropdown_menu
|
|
76
|
+
bundle exec rails senren:add form input textarea
|
|
77
|
+
bin/rails senren:agents:sync
|
|
76
78
|
bin/rails senren:doctor
|
|
77
79
|
|
|
78
80
|
Read .senren/skill.md and .senren/conventions.md to get oriented.
|