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.
Files changed (162) hide show
  1. agent_notes/VERSION +1 -0
  2. agent_notes/__init__.py +1 -0
  3. agent_notes/__main__.py +4 -0
  4. agent_notes/cli.py +348 -0
  5. agent_notes/commands/__init__.py +27 -0
  6. agent_notes/commands/_install_helpers.py +262 -0
  7. agent_notes/commands/build.py +170 -0
  8. agent_notes/commands/doctor.py +112 -0
  9. agent_notes/commands/info.py +95 -0
  10. agent_notes/commands/install.py +99 -0
  11. agent_notes/commands/list.py +169 -0
  12. agent_notes/commands/memory.py +430 -0
  13. agent_notes/commands/regenerate.py +152 -0
  14. agent_notes/commands/set_role.py +143 -0
  15. agent_notes/commands/uninstall.py +26 -0
  16. agent_notes/commands/update.py +169 -0
  17. agent_notes/commands/validate.py +199 -0
  18. agent_notes/commands/wizard.py +720 -0
  19. agent_notes/config.py +154 -0
  20. agent_notes/data/agents/agents.yaml +352 -0
  21. agent_notes/data/agents/analyst.md +45 -0
  22. agent_notes/data/agents/api-reviewer.md +47 -0
  23. agent_notes/data/agents/architect.md +46 -0
  24. agent_notes/data/agents/coder.md +28 -0
  25. agent_notes/data/agents/database-specialist.md +45 -0
  26. agent_notes/data/agents/debugger.md +47 -0
  27. agent_notes/data/agents/devil.md +47 -0
  28. agent_notes/data/agents/devops.md +38 -0
  29. agent_notes/data/agents/explorer.md +23 -0
  30. agent_notes/data/agents/integrations.md +44 -0
  31. agent_notes/data/agents/lead.md +216 -0
  32. agent_notes/data/agents/performance-profiler.md +44 -0
  33. agent_notes/data/agents/refactorer.md +48 -0
  34. agent_notes/data/agents/reviewer.md +44 -0
  35. agent_notes/data/agents/security-auditor.md +44 -0
  36. agent_notes/data/agents/system-auditor.md +38 -0
  37. agent_notes/data/agents/tech-writer.md +32 -0
  38. agent_notes/data/agents/test-runner.md +36 -0
  39. agent_notes/data/agents/test-writer.md +39 -0
  40. agent_notes/data/cli/claude.yaml +25 -0
  41. agent_notes/data/cli/copilot.yaml +18 -0
  42. agent_notes/data/cli/opencode.yaml +22 -0
  43. agent_notes/data/commands/brainstorm.md +8 -0
  44. agent_notes/data/commands/debug.md +9 -0
  45. agent_notes/data/commands/review.md +10 -0
  46. agent_notes/data/global-claude.md +290 -0
  47. agent_notes/data/global-copilot.md +27 -0
  48. agent_notes/data/global-opencode.md +40 -0
  49. agent_notes/data/hooks/session-context.md.tpl +19 -0
  50. agent_notes/data/models/claude-haiku-4-5.yaml +15 -0
  51. agent_notes/data/models/claude-opus-4-1.yaml +16 -0
  52. agent_notes/data/models/claude-opus-4-5.yaml +16 -0
  53. agent_notes/data/models/claude-opus-4-6.yaml +16 -0
  54. agent_notes/data/models/claude-opus-4-7.yaml +15 -0
  55. agent_notes/data/models/claude-sonnet-4-5.yaml +16 -0
  56. agent_notes/data/models/claude-sonnet-4-6.yaml +15 -0
  57. agent_notes/data/models/claude-sonnet-4.yaml +16 -0
  58. agent_notes/data/pricing.yaml +33 -0
  59. agent_notes/data/roles/orchestrator.yaml +5 -0
  60. agent_notes/data/roles/reasoner.yaml +5 -0
  61. agent_notes/data/roles/scout.yaml +5 -0
  62. agent_notes/data/roles/worker.yaml +5 -0
  63. agent_notes/data/rules/code-quality.md +9 -0
  64. agent_notes/data/rules/safety.md +10 -0
  65. agent_notes/data/scripts/cost-report +211 -0
  66. agent_notes/data/skills/brainstorming/SKILL.md +57 -0
  67. agent_notes/data/skills/code-review/SKILL.md +64 -0
  68. agent_notes/data/skills/debugging-protocol/SKILL.md +51 -0
  69. agent_notes/data/skills/docker-compose/SKILL.md +318 -0
  70. agent_notes/data/skills/docker-compose-advanced/SKILL.md +575 -0
  71. agent_notes/data/skills/docker-dockerfile/SKILL.md +385 -0
  72. agent_notes/data/skills/docker-dockerfile-languages/SKILL.md +293 -0
  73. agent_notes/data/skills/git/SKILL.md +87 -0
  74. agent_notes/data/skills/rails-active-storage/SKILL.md +321 -0
  75. agent_notes/data/skills/rails-broadcasting/SKILL.md +374 -0
  76. agent_notes/data/skills/rails-concerns/SKILL.md +806 -0
  77. agent_notes/data/skills/rails-controllers/SKILL.md +510 -0
  78. agent_notes/data/skills/rails-controllers-advanced/SKILL.md +441 -0
  79. agent_notes/data/skills/rails-helpers/SKILL.md +677 -0
  80. agent_notes/data/skills/rails-initializers/SKILL.md +79 -0
  81. agent_notes/data/skills/rails-javascript/SKILL.md +567 -0
  82. agent_notes/data/skills/rails-jobs/SKILL.md +700 -0
  83. agent_notes/data/skills/rails-kamal/SKILL.md +483 -0
  84. agent_notes/data/skills/rails-lib/SKILL.md +101 -0
  85. agent_notes/data/skills/rails-mailers/SKILL.md +321 -0
  86. agent_notes/data/skills/rails-migrations/SKILL.md +268 -0
  87. agent_notes/data/skills/rails-models/SKILL.md +459 -0
  88. agent_notes/data/skills/rails-models-advanced/SKILL.md +398 -0
  89. agent_notes/data/skills/rails-routes/SKILL.md +804 -0
  90. agent_notes/data/skills/rails-style/SKILL.md +538 -0
  91. agent_notes/data/skills/rails-testing-controllers/SKILL.md +343 -0
  92. agent_notes/data/skills/rails-testing-models/SKILL.md +296 -0
  93. agent_notes/data/skills/rails-testing-system/SKILL.md +375 -0
  94. agent_notes/data/skills/rails-validations/SKILL.md +108 -0
  95. agent_notes/data/skills/rails-view-components/SKILL.md +511 -0
  96. agent_notes/data/skills/rails-view-components-advanced/SKILL.md +376 -0
  97. agent_notes/data/skills/rails-views/SKILL.md +413 -0
  98. agent_notes/data/skills/rails-views-advanced/SKILL.md +450 -0
  99. agent_notes/data/skills/refactoring-protocol/SKILL.md +64 -0
  100. agent_notes/data/skills/tdd/SKILL.md +57 -0
  101. agent_notes/data/templates/__init__.py +1 -0
  102. agent_notes/data/templates/__pycache__/__init__.cpython-314.pyc +0 -0
  103. agent_notes/data/templates/frontmatter/__init__.py +1 -0
  104. agent_notes/data/templates/frontmatter/__pycache__/__init__.cpython-314.pyc +0 -0
  105. agent_notes/data/templates/frontmatter/__pycache__/claude.cpython-314.pyc +0 -0
  106. agent_notes/data/templates/frontmatter/__pycache__/cursor.cpython-314.pyc +0 -0
  107. agent_notes/data/templates/frontmatter/__pycache__/opencode.cpython-314.pyc +0 -0
  108. agent_notes/data/templates/frontmatter/claude.py +44 -0
  109. agent_notes/data/templates/frontmatter/opencode.py +104 -0
  110. agent_notes/doctor_checks.py +189 -0
  111. agent_notes/domain/__init__.py +17 -0
  112. agent_notes/domain/agent.py +34 -0
  113. agent_notes/domain/cli_backend.py +40 -0
  114. agent_notes/domain/diagnostics.py +29 -0
  115. agent_notes/domain/diff.py +44 -0
  116. agent_notes/domain/model.py +27 -0
  117. agent_notes/domain/role.py +13 -0
  118. agent_notes/domain/rule.py +13 -0
  119. agent_notes/domain/skill.py +15 -0
  120. agent_notes/domain/state.py +46 -0
  121. agent_notes/install_state.py +11 -0
  122. agent_notes/registries/__init__.py +16 -0
  123. agent_notes/registries/_base.py +46 -0
  124. agent_notes/registries/agent_registry.py +107 -0
  125. agent_notes/registries/cli_registry.py +89 -0
  126. agent_notes/registries/model_registry.py +85 -0
  127. agent_notes/registries/role_registry.py +64 -0
  128. agent_notes/registries/rule_registry.py +80 -0
  129. agent_notes/registries/skill_registry.py +141 -0
  130. agent_notes/services/__init__.py +8 -0
  131. agent_notes/services/diagnostics/__init__.py +47 -0
  132. agent_notes/services/diagnostics/_checks.py +272 -0
  133. agent_notes/services/diagnostics/_display.py +346 -0
  134. agent_notes/services/diagnostics/_fix.py +169 -0
  135. agent_notes/services/diff.py +349 -0
  136. agent_notes/services/fs.py +195 -0
  137. agent_notes/services/install_state_builder.py +210 -0
  138. agent_notes/services/installer.py +293 -0
  139. agent_notes/services/memory_backend.py +155 -0
  140. agent_notes/services/rendering.py +329 -0
  141. agent_notes/services/session_context.py +23 -0
  142. agent_notes/services/settings_writer.py +79 -0
  143. agent_notes/services/state_store.py +249 -0
  144. agent_notes/services/ui.py +419 -0
  145. agent_notes/services/user_config.py +62 -0
  146. agent_notes/services/validation.py +67 -0
  147. agent_notes/state.py +21 -0
  148. agent_notes-2.0.4.dist-info/METADATA +14 -0
  149. agent_notes-2.0.4.dist-info/RECORD +162 -0
  150. agent_notes-2.0.4.dist-info/WHEEL +5 -0
  151. agent_notes-2.0.4.dist-info/entry_points.txt +2 -0
  152. agent_notes-2.0.4.dist-info/licenses/LICENSE +21 -0
  153. agent_notes-2.0.4.dist-info/top_level.txt +2 -0
  154. tests/conftest.py +20 -0
  155. tests/functional/__init__.py +0 -0
  156. tests/functional/test_build_commands.py +88 -0
  157. tests/functional/test_registries.py +128 -0
  158. tests/integration/__init__.py +0 -0
  159. tests/integration/test_build_output.py +129 -0
  160. tests/plugins/__init__.py +0 -0
  161. tests/plugins/test_agents.py +93 -0
  162. 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
+ "&nbsp;".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