agent-notes 2.0.4__py3-none-any.whl
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.
- agent_notes/VERSION +1 -0
- agent_notes/__init__.py +1 -0
- agent_notes/__main__.py +4 -0
- agent_notes/cli.py +348 -0
- agent_notes/commands/__init__.py +27 -0
- agent_notes/commands/_install_helpers.py +262 -0
- agent_notes/commands/build.py +170 -0
- agent_notes/commands/doctor.py +112 -0
- agent_notes/commands/info.py +95 -0
- agent_notes/commands/install.py +99 -0
- agent_notes/commands/list.py +169 -0
- agent_notes/commands/memory.py +430 -0
- agent_notes/commands/regenerate.py +152 -0
- agent_notes/commands/set_role.py +143 -0
- agent_notes/commands/uninstall.py +26 -0
- agent_notes/commands/update.py +169 -0
- agent_notes/commands/validate.py +199 -0
- agent_notes/commands/wizard.py +720 -0
- agent_notes/config.py +154 -0
- agent_notes/data/agents/agents.yaml +352 -0
- agent_notes/data/agents/analyst.md +45 -0
- agent_notes/data/agents/api-reviewer.md +47 -0
- agent_notes/data/agents/architect.md +46 -0
- agent_notes/data/agents/coder.md +28 -0
- agent_notes/data/agents/database-specialist.md +45 -0
- agent_notes/data/agents/debugger.md +47 -0
- agent_notes/data/agents/devil.md +47 -0
- agent_notes/data/agents/devops.md +38 -0
- agent_notes/data/agents/explorer.md +23 -0
- agent_notes/data/agents/integrations.md +44 -0
- agent_notes/data/agents/lead.md +216 -0
- agent_notes/data/agents/performance-profiler.md +44 -0
- agent_notes/data/agents/refactorer.md +48 -0
- agent_notes/data/agents/reviewer.md +44 -0
- agent_notes/data/agents/security-auditor.md +44 -0
- agent_notes/data/agents/system-auditor.md +38 -0
- agent_notes/data/agents/tech-writer.md +32 -0
- agent_notes/data/agents/test-runner.md +36 -0
- agent_notes/data/agents/test-writer.md +39 -0
- agent_notes/data/cli/claude.yaml +25 -0
- agent_notes/data/cli/copilot.yaml +18 -0
- agent_notes/data/cli/opencode.yaml +22 -0
- agent_notes/data/commands/brainstorm.md +8 -0
- agent_notes/data/commands/debug.md +9 -0
- agent_notes/data/commands/review.md +10 -0
- agent_notes/data/global-claude.md +290 -0
- agent_notes/data/global-copilot.md +27 -0
- agent_notes/data/global-opencode.md +40 -0
- agent_notes/data/hooks/session-context.md.tpl +19 -0
- agent_notes/data/models/claude-haiku-4-5.yaml +15 -0
- agent_notes/data/models/claude-opus-4-1.yaml +16 -0
- agent_notes/data/models/claude-opus-4-5.yaml +16 -0
- agent_notes/data/models/claude-opus-4-6.yaml +16 -0
- agent_notes/data/models/claude-opus-4-7.yaml +15 -0
- agent_notes/data/models/claude-sonnet-4-5.yaml +16 -0
- agent_notes/data/models/claude-sonnet-4-6.yaml +15 -0
- agent_notes/data/models/claude-sonnet-4.yaml +16 -0
- agent_notes/data/pricing.yaml +33 -0
- agent_notes/data/roles/orchestrator.yaml +5 -0
- agent_notes/data/roles/reasoner.yaml +5 -0
- agent_notes/data/roles/scout.yaml +5 -0
- agent_notes/data/roles/worker.yaml +5 -0
- agent_notes/data/rules/code-quality.md +9 -0
- agent_notes/data/rules/safety.md +10 -0
- agent_notes/data/scripts/cost-report +211 -0
- agent_notes/data/skills/brainstorming/SKILL.md +57 -0
- agent_notes/data/skills/code-review/SKILL.md +64 -0
- agent_notes/data/skills/debugging-protocol/SKILL.md +51 -0
- agent_notes/data/skills/docker-compose/SKILL.md +318 -0
- agent_notes/data/skills/docker-compose-advanced/SKILL.md +575 -0
- agent_notes/data/skills/docker-dockerfile/SKILL.md +385 -0
- agent_notes/data/skills/docker-dockerfile-languages/SKILL.md +293 -0
- agent_notes/data/skills/git/SKILL.md +87 -0
- agent_notes/data/skills/rails-active-storage/SKILL.md +321 -0
- agent_notes/data/skills/rails-broadcasting/SKILL.md +374 -0
- agent_notes/data/skills/rails-concerns/SKILL.md +806 -0
- agent_notes/data/skills/rails-controllers/SKILL.md +510 -0
- agent_notes/data/skills/rails-controllers-advanced/SKILL.md +441 -0
- agent_notes/data/skills/rails-helpers/SKILL.md +677 -0
- agent_notes/data/skills/rails-initializers/SKILL.md +79 -0
- agent_notes/data/skills/rails-javascript/SKILL.md +567 -0
- agent_notes/data/skills/rails-jobs/SKILL.md +700 -0
- agent_notes/data/skills/rails-kamal/SKILL.md +483 -0
- agent_notes/data/skills/rails-lib/SKILL.md +101 -0
- agent_notes/data/skills/rails-mailers/SKILL.md +321 -0
- agent_notes/data/skills/rails-migrations/SKILL.md +268 -0
- agent_notes/data/skills/rails-models/SKILL.md +459 -0
- agent_notes/data/skills/rails-models-advanced/SKILL.md +398 -0
- agent_notes/data/skills/rails-routes/SKILL.md +804 -0
- agent_notes/data/skills/rails-style/SKILL.md +538 -0
- agent_notes/data/skills/rails-testing-controllers/SKILL.md +343 -0
- agent_notes/data/skills/rails-testing-models/SKILL.md +296 -0
- agent_notes/data/skills/rails-testing-system/SKILL.md +375 -0
- agent_notes/data/skills/rails-validations/SKILL.md +108 -0
- agent_notes/data/skills/rails-view-components/SKILL.md +511 -0
- agent_notes/data/skills/rails-view-components-advanced/SKILL.md +376 -0
- agent_notes/data/skills/rails-views/SKILL.md +413 -0
- agent_notes/data/skills/rails-views-advanced/SKILL.md +450 -0
- agent_notes/data/skills/refactoring-protocol/SKILL.md +64 -0
- agent_notes/data/skills/tdd/SKILL.md +57 -0
- agent_notes/data/templates/__init__.py +1 -0
- agent_notes/data/templates/__pycache__/__init__.cpython-314.pyc +0 -0
- agent_notes/data/templates/frontmatter/__init__.py +1 -0
- agent_notes/data/templates/frontmatter/__pycache__/__init__.cpython-314.pyc +0 -0
- agent_notes/data/templates/frontmatter/__pycache__/claude.cpython-314.pyc +0 -0
- agent_notes/data/templates/frontmatter/__pycache__/cursor.cpython-314.pyc +0 -0
- agent_notes/data/templates/frontmatter/__pycache__/opencode.cpython-314.pyc +0 -0
- agent_notes/data/templates/frontmatter/claude.py +44 -0
- agent_notes/data/templates/frontmatter/opencode.py +104 -0
- agent_notes/doctor_checks.py +189 -0
- agent_notes/domain/__init__.py +17 -0
- agent_notes/domain/agent.py +34 -0
- agent_notes/domain/cli_backend.py +40 -0
- agent_notes/domain/diagnostics.py +29 -0
- agent_notes/domain/diff.py +44 -0
- agent_notes/domain/model.py +27 -0
- agent_notes/domain/role.py +13 -0
- agent_notes/domain/rule.py +13 -0
- agent_notes/domain/skill.py +15 -0
- agent_notes/domain/state.py +46 -0
- agent_notes/install_state.py +11 -0
- agent_notes/registries/__init__.py +16 -0
- agent_notes/registries/_base.py +46 -0
- agent_notes/registries/agent_registry.py +107 -0
- agent_notes/registries/cli_registry.py +89 -0
- agent_notes/registries/model_registry.py +85 -0
- agent_notes/registries/role_registry.py +64 -0
- agent_notes/registries/rule_registry.py +80 -0
- agent_notes/registries/skill_registry.py +141 -0
- agent_notes/services/__init__.py +8 -0
- agent_notes/services/diagnostics/__init__.py +47 -0
- agent_notes/services/diagnostics/_checks.py +272 -0
- agent_notes/services/diagnostics/_display.py +346 -0
- agent_notes/services/diagnostics/_fix.py +169 -0
- agent_notes/services/diff.py +349 -0
- agent_notes/services/fs.py +195 -0
- agent_notes/services/install_state_builder.py +210 -0
- agent_notes/services/installer.py +293 -0
- agent_notes/services/memory_backend.py +155 -0
- agent_notes/services/rendering.py +329 -0
- agent_notes/services/session_context.py +23 -0
- agent_notes/services/settings_writer.py +79 -0
- agent_notes/services/state_store.py +249 -0
- agent_notes/services/ui.py +419 -0
- agent_notes/services/user_config.py +62 -0
- agent_notes/services/validation.py +67 -0
- agent_notes/state.py +21 -0
- agent_notes-2.0.4.dist-info/METADATA +14 -0
- agent_notes-2.0.4.dist-info/RECORD +162 -0
- agent_notes-2.0.4.dist-info/WHEEL +5 -0
- agent_notes-2.0.4.dist-info/entry_points.txt +2 -0
- agent_notes-2.0.4.dist-info/licenses/LICENSE +21 -0
- agent_notes-2.0.4.dist-info/top_level.txt +2 -0
- tests/conftest.py +20 -0
- tests/functional/__init__.py +0 -0
- tests/functional/test_build_commands.py +88 -0
- tests/functional/test_registries.py +128 -0
- tests/integration/__init__.py +0 -0
- tests/integration/test_build_output.py +129 -0
- tests/plugins/__init__.py +0 -0
- tests/plugins/test_agents.py +93 -0
- tests/plugins/test_skills.py +77 -0
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rails-helpers
|
|
3
|
+
description: "Rails view helpers: application helpers, domain-specific helpers, forms, and HTML processing"
|
|
4
|
+
group: rails
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Helpers Guide
|
|
8
|
+
|
|
9
|
+
Comprehensive guide for Rails view helpers.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Philosophy
|
|
14
|
+
|
|
15
|
+
1. **Helpers are for view logic only** - Not business logic
|
|
16
|
+
2. **Domain-specific helpers** - One helper per resource
|
|
17
|
+
3. **Keep helpers focused** - Single responsibility
|
|
18
|
+
4. **No database queries** - Pass data from controller
|
|
19
|
+
5. **Tag builders over string concatenation** - Use `tag` helpers
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## File Structure
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
app/helpers/
|
|
27
|
+
├── application_helper.rb # Global helpers
|
|
28
|
+
├── cards_helper.rb # Card-specific helpers
|
|
29
|
+
├── boards_helper.rb # Board-specific helpers
|
|
30
|
+
├── forms_helper.rb # Form helpers
|
|
31
|
+
├── time_helper.rb # Time/date helpers
|
|
32
|
+
└── html_helper.rb # HTML processing
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Application Helper
|
|
38
|
+
|
|
39
|
+
### Global Helpers
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# app/helpers/application_helper.rb
|
|
43
|
+
module ApplicationHelper
|
|
44
|
+
# Page title with cascading defaults
|
|
45
|
+
def page_title_tag
|
|
46
|
+
parts = [@page_title, Current.account&.name, "My App"].compact
|
|
47
|
+
tag.title parts.join(" | ")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Icon rendering
|
|
51
|
+
def icon_tag(name, **options)
|
|
52
|
+
tag.span(
|
|
53
|
+
class: class_names("icon icon--#{name}", options.delete(:class)),
|
|
54
|
+
"aria-hidden": true,
|
|
55
|
+
**options
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# SVG inline
|
|
60
|
+
def inline_svg(name, **options)
|
|
61
|
+
file_path = Rails.root.join("app/assets/images/#{name}.svg")
|
|
62
|
+
return "(not found)" unless File.exist?(file_path)
|
|
63
|
+
|
|
64
|
+
svg = File.read(file_path)
|
|
65
|
+
tag.div(svg.html_safe, **options)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Flash message rendering
|
|
69
|
+
def flash_messages
|
|
70
|
+
flash.map do |type, message|
|
|
71
|
+
tag.div(message, class: "alert alert-#{type}", role: "alert")
|
|
72
|
+
end.join.html_safe
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Navigation helpers
|
|
76
|
+
def nav_link_to(text, path, **options)
|
|
77
|
+
active = current_page?(path)
|
|
78
|
+
css_class = class_names(options.delete(:class), "active" => active)
|
|
79
|
+
|
|
80
|
+
link_to text, path, class: css_class, **options
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Boolean to Yes/No
|
|
84
|
+
def yes_no(boolean)
|
|
85
|
+
boolean ? "Yes" : "No"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Gravatar
|
|
89
|
+
def gravatar_url(email, size: 80)
|
|
90
|
+
hash = Digest::MD5.hexdigest(email.downcase)
|
|
91
|
+
"https://www.gravatar.com/avatar/#{hash}?s=#{size}&d=identicon"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Domain-Specific Helpers
|
|
99
|
+
|
|
100
|
+
### Cards Helper
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
# app/helpers/cards_helper.rb
|
|
104
|
+
module CardsHelper
|
|
105
|
+
# Custom tag builders
|
|
106
|
+
def card_article_tag(card, **options, &block)
|
|
107
|
+
classes = [
|
|
108
|
+
options.delete(:class),
|
|
109
|
+
("golden-effect" if card.golden?),
|
|
110
|
+
("card--postponed" if card.postponed?),
|
|
111
|
+
("card--active" if card.active?)
|
|
112
|
+
].compact.join(" ")
|
|
113
|
+
|
|
114
|
+
data = options.delete(:data) || {}
|
|
115
|
+
data[:card_id] = card.id
|
|
116
|
+
data[:drag_and_drop] = true if card.golden?
|
|
117
|
+
|
|
118
|
+
tag.article(
|
|
119
|
+
id: dom_id(card),
|
|
120
|
+
style: "--card-color: #{card.color}",
|
|
121
|
+
class: classes,
|
|
122
|
+
data: data,
|
|
123
|
+
**options,
|
|
124
|
+
&block
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Status badge
|
|
129
|
+
def card_status_badge(card)
|
|
130
|
+
tag.span(
|
|
131
|
+
card.status.humanize,
|
|
132
|
+
class: "badge badge-#{card.status}"
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Card title with metadata
|
|
137
|
+
def card_title_tag(card)
|
|
138
|
+
parts = [
|
|
139
|
+
card.title,
|
|
140
|
+
"added by #{card.creator.name}",
|
|
141
|
+
"in #{card.board.name}"
|
|
142
|
+
]
|
|
143
|
+
parts << "assigned to #{card.assignees.map(&:name).to_sentence}" if card.assignees.any?
|
|
144
|
+
|
|
145
|
+
tag.title parts.join(" ")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Social meta tags
|
|
149
|
+
def card_social_tags(card)
|
|
150
|
+
[
|
|
151
|
+
tag.meta(property: "og:title", content: "#{card.title} | #{card.board.name}"),
|
|
152
|
+
tag.meta(property: "og:description", content: excerpt_text(card.description, length: 200)),
|
|
153
|
+
tag.meta(property: "og:image", content: card_image_url(card)),
|
|
154
|
+
tag.meta(property: "og:url", content: card_url(card))
|
|
155
|
+
].join.html_safe
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Button helpers
|
|
159
|
+
def button_to_close_card(card)
|
|
160
|
+
button_to card_closure_path(card), method: :post, class: "btn btn-secondary" do
|
|
161
|
+
icon_tag("close") + tag.span("Close")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def button_to_remove_card_image(card)
|
|
166
|
+
return unless card.image.attached?
|
|
167
|
+
|
|
168
|
+
button_to card_image_path(card), method: :delete, class: "btn btn-danger",
|
|
169
|
+
data: { confirm: "Remove image?" } do
|
|
170
|
+
icon_tag("trash") + tag.span("Remove image", class: "sr-only")
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
def card_image_url(card)
|
|
176
|
+
if card.image.attached?
|
|
177
|
+
url_for(card.image)
|
|
178
|
+
else
|
|
179
|
+
asset_url("default-card.png")
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def excerpt_text(rich_text, length: 200)
|
|
184
|
+
truncate(rich_text.to_plain_text, length: length)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Boards Helper
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
# app/helpers/boards_helper.rb
|
|
193
|
+
module BoardsHelper
|
|
194
|
+
def board_access_badge(board)
|
|
195
|
+
text = board.all_access? ? "All Access" : "Restricted"
|
|
196
|
+
css_class = board.all_access? ? "success" : "warning"
|
|
197
|
+
|
|
198
|
+
tag.span text, class: "badge badge-#{css_class}"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def board_card_count(board)
|
|
202
|
+
count = board.cards.published.count
|
|
203
|
+
tag.span pluralize(count, "card"), class: "card-count"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def board_members_list(board, limit: 5)
|
|
207
|
+
members = board.users.limit(limit)
|
|
208
|
+
overflow = board.users.count - limit
|
|
209
|
+
|
|
210
|
+
content_tag :div, class: "members-list" do
|
|
211
|
+
members.map { |user| user_avatar(user, size: :small) }.join.html_safe +
|
|
212
|
+
(overflow > 0 ? tag.span("+#{overflow}", class: "overflow") : "".html_safe)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def user_avatar(user, size: :medium)
|
|
217
|
+
size_class = "avatar-#{size}"
|
|
218
|
+
|
|
219
|
+
if user.avatar.attached?
|
|
220
|
+
image_tag user.avatar.variant(resize_to_fill: [40, 40]),
|
|
221
|
+
alt: user.name,
|
|
222
|
+
class: "avatar #{size_class}"
|
|
223
|
+
else
|
|
224
|
+
tag.div(
|
|
225
|
+
user.initials,
|
|
226
|
+
class: "avatar avatar-placeholder #{size_class}",
|
|
227
|
+
style: "background-color: #{user.avatar_color}"
|
|
228
|
+
)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## Form Helpers
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
# app/helpers/forms_helper.rb
|
|
240
|
+
module FormsHelper
|
|
241
|
+
# Auto-submit form
|
|
242
|
+
def auto_submit_form_with(**attributes, &block)
|
|
243
|
+
data = attributes.delete(:data) || {}
|
|
244
|
+
data[:controller] = "auto-submit #{data[:controller]}".strip
|
|
245
|
+
|
|
246
|
+
form_with(**attributes, data: data, &block)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Error messages for form
|
|
250
|
+
def form_errors_for(model)
|
|
251
|
+
return unless model.errors.any?
|
|
252
|
+
|
|
253
|
+
tag.div class: "error-messages" do
|
|
254
|
+
tag.h3("#{pluralize(model.errors.count, "error")} prohibited this from being saved:") +
|
|
255
|
+
tag.ul do
|
|
256
|
+
model.errors.full_messages.map { |msg| tag.li(msg) }.join.html_safe
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Field with error
|
|
262
|
+
def field_with_errors(model, attribute, &block)
|
|
263
|
+
css_class = model.errors[attribute].any? ? "field field-with-errors" : "field"
|
|
264
|
+
|
|
265
|
+
tag.div class: css_class do
|
|
266
|
+
capture(&block) +
|
|
267
|
+
(model.errors[attribute].any? ? tag.span(model.errors[attribute].join(", "), class: "error") : "".html_safe)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Required field marker
|
|
272
|
+
def required_marker
|
|
273
|
+
tag.span "*", class: "required", "aria-label": "required"
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## Time Helpers
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
# app/helpers/time_helper.rb
|
|
284
|
+
module TimeHelper
|
|
285
|
+
# Local datetime (converted via JS)
|
|
286
|
+
def local_datetime_tag(datetime, style: :time, **attributes)
|
|
287
|
+
tag.time(
|
|
288
|
+
" ".html_safe, # Placeholder
|
|
289
|
+
**attributes,
|
|
290
|
+
datetime: datetime.to_i,
|
|
291
|
+
data: {
|
|
292
|
+
local_time_target: style,
|
|
293
|
+
action: "turbo:morph-element->local-time#refreshTarget"
|
|
294
|
+
}
|
|
295
|
+
)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Relative time
|
|
299
|
+
def relative_time_tag(datetime, **options)
|
|
300
|
+
tag.time time_ago_in_words(datetime) + " ago",
|
|
301
|
+
datetime: datetime.iso8601,
|
|
302
|
+
title: datetime.strftime("%B %d, %Y at %l:%M %p"),
|
|
303
|
+
**options
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Business days ago
|
|
307
|
+
def business_days_ago(date)
|
|
308
|
+
days = (Date.current - date.to_date).to_i
|
|
309
|
+
weekends = (date.to_date..Date.current).count { |d| d.saturday? || d.sunday? }
|
|
310
|
+
business_days = days - weekends
|
|
311
|
+
|
|
312
|
+
pluralize(business_days, "business day")
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Duration in words
|
|
316
|
+
def duration_in_words(seconds)
|
|
317
|
+
return "less than a minute" if seconds < 60
|
|
318
|
+
|
|
319
|
+
hours = seconds / 3600
|
|
320
|
+
minutes = (seconds % 3600) / 60
|
|
321
|
+
|
|
322
|
+
parts = []
|
|
323
|
+
parts << "#{hours} #{hours == 1 ? 'hour' : 'hours'}" if hours > 0
|
|
324
|
+
parts << "#{minutes} #{minutes == 1 ? 'minute' : 'minutes'}" if minutes > 0
|
|
325
|
+
|
|
326
|
+
parts.join(" and ")
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
## HTML Processing Helpers
|
|
334
|
+
|
|
335
|
+
```ruby
|
|
336
|
+
# app/helpers/html_helper.rb
|
|
337
|
+
module HtmlHelper
|
|
338
|
+
include ERB::Util
|
|
339
|
+
|
|
340
|
+
EXCLUDE_PUNCTUATION = %(.?,:!;"'<>)
|
|
341
|
+
EXCLUDE_PUNCTUATION_REGEX = /[#{Regexp.escape(EXCLUDE_PUNCTUATION)}]+\z/
|
|
342
|
+
|
|
343
|
+
# Auto-link URLs and emails
|
|
344
|
+
def format_html(html)
|
|
345
|
+
fragment = Nokogiri::HTML5.fragment(html)
|
|
346
|
+
auto_link(fragment)
|
|
347
|
+
fragment.to_html.html_safe
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Excerpt with highlighting
|
|
351
|
+
def smart_excerpt(text, query, radius: 50)
|
|
352
|
+
return truncate(text, length: radius * 2) if query.blank?
|
|
353
|
+
|
|
354
|
+
highlighted = highlight(text, query, highlighter: '<mark>\1</mark>')
|
|
355
|
+
excerpt(highlighted, query, radius: radius).html_safe
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Markdown to HTML (if using markdown)
|
|
359
|
+
def markdown(text)
|
|
360
|
+
return "" if text.blank?
|
|
361
|
+
|
|
362
|
+
markdown_renderer.render(text).html_safe
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
private
|
|
366
|
+
EXCLUDED_ELEMENTS = %w[ a figcaption pre code ]
|
|
367
|
+
EMAIL_REGEX = /\b[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\b/
|
|
368
|
+
URL_REGEXP = URI::DEFAULT_PARSER.make_regexp(%w[http https])
|
|
369
|
+
|
|
370
|
+
def auto_link(fragment)
|
|
371
|
+
fragment.traverse do |node|
|
|
372
|
+
next unless auto_linkable_node?(node)
|
|
373
|
+
|
|
374
|
+
content = h(node.text)
|
|
375
|
+
linked_content = content.dup
|
|
376
|
+
|
|
377
|
+
auto_link_urls(linked_content)
|
|
378
|
+
auto_link_emails(linked_content)
|
|
379
|
+
|
|
380
|
+
node.replace(Nokogiri::HTML5.fragment(linked_content)) if linked_content != content
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def auto_linkable_node?(node)
|
|
385
|
+
node.text? && node.ancestors.none? { |ancestor|
|
|
386
|
+
EXCLUDED_ELEMENTS.include?(ancestor.name)
|
|
387
|
+
}
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def auto_link_urls(text)
|
|
391
|
+
text.gsub!(URL_REGEXP) do |match|
|
|
392
|
+
url, punctuation = extract_url_and_punctuation(match)
|
|
393
|
+
%(<a href="#{url}" rel="noreferrer">#{url}</a>#{punctuation})
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def extract_url_and_punctuation(url_match)
|
|
398
|
+
url_match = CGI.unescapeHTML(url_match)
|
|
399
|
+
if match = url_match.match(EXCLUDE_PUNCTUATION_REGEX)
|
|
400
|
+
len = match[0].length
|
|
401
|
+
[url_match[..-(len+1)], url_match[-len..]]
|
|
402
|
+
else
|
|
403
|
+
[url_match, ""]
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def auto_link_emails(text)
|
|
408
|
+
text.gsub!(EMAIL_REGEX) do |match|
|
|
409
|
+
%(<a href="mailto:#{match}">#{match}</a>)
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def markdown_renderer
|
|
414
|
+
@markdown_renderer ||= Redcarpet::Markdown.new(
|
|
415
|
+
Redcarpet::Render::HTML,
|
|
416
|
+
autolink: true,
|
|
417
|
+
tables: true,
|
|
418
|
+
fenced_code_blocks: true
|
|
419
|
+
)
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
## Pagination Helpers
|
|
427
|
+
|
|
428
|
+
```ruby
|
|
429
|
+
# app/helpers/pagination_helper.rb
|
|
430
|
+
module PaginationHelper
|
|
431
|
+
def pagination_links(collection, **options)
|
|
432
|
+
return if collection.total_pages <= 1
|
|
433
|
+
|
|
434
|
+
tag.nav class: "pagination", **options do
|
|
435
|
+
[
|
|
436
|
+
previous_page_link(collection),
|
|
437
|
+
page_links(collection),
|
|
438
|
+
next_page_link(collection)
|
|
439
|
+
].join.html_safe
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
private
|
|
444
|
+
def previous_page_link(collection)
|
|
445
|
+
if collection.prev_page
|
|
446
|
+
link_to "Previous", url_for(page: collection.prev_page), class: "pagination-link"
|
|
447
|
+
else
|
|
448
|
+
tag.span "Previous", class: "pagination-link disabled"
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def next_page_link(collection)
|
|
453
|
+
if collection.next_page
|
|
454
|
+
link_to "Next", url_for(page: collection.next_page), class: "pagination-link"
|
|
455
|
+
else
|
|
456
|
+
tag.span "Next", class: "pagination-link disabled"
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def page_links(collection)
|
|
461
|
+
window = 2
|
|
462
|
+
start_page = [collection.current_page - window, 1].max
|
|
463
|
+
end_page = [collection.current_page + window, collection.total_pages].min
|
|
464
|
+
|
|
465
|
+
(start_page..end_page).map do |page|
|
|
466
|
+
if page == collection.current_page
|
|
467
|
+
tag.span page, class: "pagination-link active"
|
|
468
|
+
else
|
|
469
|
+
link_to page, url_for(page: page), class: "pagination-link"
|
|
470
|
+
end
|
|
471
|
+
end.join.html_safe
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
---
|
|
477
|
+
|
|
478
|
+
## Component Helpers
|
|
479
|
+
|
|
480
|
+
```ruby
|
|
481
|
+
# app/helpers/components_helper.rb
|
|
482
|
+
module ComponentsHelper
|
|
483
|
+
def alert_box(type: :info, dismissible: true, &block)
|
|
484
|
+
tag.div class: "alert alert-#{type} #{dismissible ? 'alert-dismissible' : ''}", role: "alert" do
|
|
485
|
+
content = capture(&block)
|
|
486
|
+
content += dismiss_button if dismissible
|
|
487
|
+
content
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def badge(text, type: :primary, **options)
|
|
492
|
+
tag.span text, class: class_names("badge badge-#{type}", options.delete(:class)), **options
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def card(**options, &block)
|
|
496
|
+
tag.div class: class_names("card", options.delete(:class)), **options do
|
|
497
|
+
capture(&block)
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def modal(id, title: nil, size: :medium, &block)
|
|
502
|
+
tag.div id: id, class: "modal modal-#{size}", data: { controller: "modal" } do
|
|
503
|
+
tag.div class: "modal-content" do
|
|
504
|
+
modal_header(title) +
|
|
505
|
+
tag.div(class: "modal-body", &block) +
|
|
506
|
+
modal_footer
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
private
|
|
512
|
+
def dismiss_button
|
|
513
|
+
tag.button type: "button", class: "close", data: { dismiss: "alert" } do
|
|
514
|
+
tag.span("×", "aria-hidden": true)
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def modal_header(title)
|
|
519
|
+
return "".html_safe unless title
|
|
520
|
+
|
|
521
|
+
tag.div class: "modal-header" do
|
|
522
|
+
tag.h5(title, class: "modal-title") +
|
|
523
|
+
tag.button(type: "button", class: "close", data: { dismiss: "modal" }) do
|
|
524
|
+
tag.span("×")
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def modal_footer
|
|
530
|
+
tag.div class: "modal-footer" do
|
|
531
|
+
tag.button "Close", type: "button", class: "btn btn-secondary", data: { dismiss: "modal" }
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
---
|
|
538
|
+
|
|
539
|
+
## Best Practices
|
|
540
|
+
|
|
541
|
+
### ✅ DO
|
|
542
|
+
|
|
543
|
+
1. **Use tag builders**
|
|
544
|
+
```ruby
|
|
545
|
+
# Good
|
|
546
|
+
tag.div "Content", class: "card", id: "card-1"
|
|
547
|
+
|
|
548
|
+
# Bad
|
|
549
|
+
"<div class='card' id='card-1'>Content</div>".html_safe
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
2. **Domain-specific helpers**
|
|
553
|
+
```ruby
|
|
554
|
+
# app/helpers/cards_helper.rb
|
|
555
|
+
def card_status_badge(card)
|
|
556
|
+
tag.span card.status, class: "badge badge-#{card.status}"
|
|
557
|
+
end
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
3. **Private helper methods**
|
|
561
|
+
```ruby
|
|
562
|
+
module CardsHelper
|
|
563
|
+
def card_title(card)
|
|
564
|
+
format_title(card.title)
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
private
|
|
568
|
+
def format_title(title)
|
|
569
|
+
title.titleize.truncate(50)
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
4. **Extract complex logic**
|
|
575
|
+
```ruby
|
|
576
|
+
# Good - in helper
|
|
577
|
+
def user_display_name(user)
|
|
578
|
+
user.name.presence || user.email.split("@").first
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# Bad - in view
|
|
582
|
+
<%= user.name.presence || user.email.split("@").first %>
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
### ❌ DON'T
|
|
586
|
+
|
|
587
|
+
1. **Business logic**
|
|
588
|
+
```ruby
|
|
589
|
+
# Bad
|
|
590
|
+
def can_edit_card?(card)
|
|
591
|
+
current_user.admin? || card.creator == current_user
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# Good - put in model or policy
|
|
595
|
+
def can_edit_card?(card)
|
|
596
|
+
card.editable_by?(current_user)
|
|
597
|
+
end
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
2. **Database queries**
|
|
601
|
+
```ruby
|
|
602
|
+
# Bad
|
|
603
|
+
def recent_cards
|
|
604
|
+
Card.where(created_at: 1.week.ago..).limit(5)
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# Good - query in controller, helper formats
|
|
608
|
+
def recent_cards_list(cards)
|
|
609
|
+
cards.map { |card| card_link(card) }.join(", ").html_safe
|
|
610
|
+
end
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
3. **Complex conditionals**
|
|
614
|
+
```ruby
|
|
615
|
+
# Bad
|
|
616
|
+
def card_class(card)
|
|
617
|
+
if card.published? && card.active? && !card.archived?
|
|
618
|
+
"card-active"
|
|
619
|
+
elsif card.draft?
|
|
620
|
+
"card-draft"
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# Good - push to model
|
|
625
|
+
def card_class(card)
|
|
626
|
+
"card-#{card.display_state}"
|
|
627
|
+
end
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
---
|
|
631
|
+
|
|
632
|
+
## Testing Helpers
|
|
633
|
+
|
|
634
|
+
```ruby
|
|
635
|
+
# test/helpers/cards_helper_test.rb
|
|
636
|
+
class CardsHelperTest < ActionView::TestCase
|
|
637
|
+
test "card_status_badge returns correct badge" do
|
|
638
|
+
card = cards(:published)
|
|
639
|
+
|
|
640
|
+
badge = card_status_badge(card)
|
|
641
|
+
|
|
642
|
+
assert_match /badge/, badge
|
|
643
|
+
assert_match /published/, badge
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
test "card_article_tag includes golden effect for golden cards" do
|
|
647
|
+
card = cards(:logo)
|
|
648
|
+
card.stub(:golden?, true) do
|
|
649
|
+
result = card_article_tag(card) { "Content" }
|
|
650
|
+
|
|
651
|
+
assert_match /golden-effect/, result
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
test "card_social_tags includes og meta tags" do
|
|
656
|
+
card = cards(:logo)
|
|
657
|
+
|
|
658
|
+
tags = card_social_tags(card)
|
|
659
|
+
|
|
660
|
+
assert_match /og:title/, tags
|
|
661
|
+
assert_match /og:description/, tags
|
|
662
|
+
assert_match card.title, tags
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
---
|
|
668
|
+
|
|
669
|
+
## Summary
|
|
670
|
+
|
|
671
|
+
- **Purpose**: View presentation logic only, no business logic
|
|
672
|
+
- **Organization**: Domain-specific helpers per resource
|
|
673
|
+
- **Tag Builders**: Use `tag` helpers over string concatenation
|
|
674
|
+
- **Focused**: Single responsibility per helper method
|
|
675
|
+
- **Private**: Use private methods for helper implementation details
|
|
676
|
+
- **Testing**: Test complex helpers, skip simple ones
|
|
677
|
+
- **No Queries**: Pass data from controller, don't query in helpers
|