and_one 0.1.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9ee084901a4915dba0065a11cf14a6996f277d6ca5fba2efd388b27ddc2f349a
4
- data.tar.gz: 05d02c49d473359e70ba9b300711e6d15f52d391ab48e22ca1ddb8651e3eb7bc
3
+ metadata.gz: 51b6c654539d7d462eee3fa1f498e12c67d083032532f70e9068357aaf720813
4
+ data.tar.gz: 9fbd318564ff10362c9e7f24bed9f05654a4db1cb19b2b6f80e76fe7748ec380
5
5
  SHA512:
6
- metadata.gz: 5f59a82aac0fc38ef5f9d2d11b633a1b881378124834d8d8ec0dd4ecb85e887d16aec5feee30225205df60ab90cb1577eb02ebca0306685736231faad0e53799
7
- data.tar.gz: '0663708679e397440e6e1fe26bee567e868e1909e1138cfb94d3da085cabdc4da8808005f72a610aa6f99fde5f262ffead6ff57bd4bc21e0b685c9c07195df0b'
6
+ metadata.gz: e616668cf8527dd81bdf5d31ab89974ba651a8cdb587f8ff33e5a71fbdae9077474d28da036fc4689ab01fe10a4b7814006c54f25026356606245733c75fab95
7
+ data.tar.gz: e4efa724ed029637706a90007a8e4f16005e15c4278c79fb7d6e1dec4a2c2d8bdab03c6b21ae3da9378b53795e7e4aaa39704bdab878880506e021ecfc075edb
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bash
2
+ set -e
3
+
4
+ # Get staged Ruby files
5
+ STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.rb$' || true)
6
+
7
+ if [ -n "$STAGED_FILES" ]; then
8
+ echo "Running RuboCop on staged files..."
9
+ bundle exec rubocop --force-exclusion $STAGED_FILES
10
+ fi
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-03-05
4
+
5
+ ### Removed
6
+
7
+ - **`aggregate_mode` configuration option** — Deduplication and aggregate tracking are now always-on when AndOne is enabled. The dev toast and dashboard both depend on the aggregate, so having it off was a bug in disguise (detections would show in the toast but never appear on the dashboard). If you had `AndOne.aggregate_mode = true` in an initializer, simply remove the line.
8
+
9
+ ### Fixed
10
+
11
+ - N+1 detections now always appear on the `/__and_one` dashboard. Previously, detections would show in the toast notification but not on the dashboard unless `aggregate_mode` was explicitly enabled.
12
+
13
+ ## [0.2.0] - 2026-03-02
14
+
15
+ ### Added
16
+
17
+ - **Dev toast notifications** — When an N+1 is detected during a request, a toast notification is injected into the bottom-right corner of the page showing which tables were affected with a link to the `/__and_one` dashboard. Enabled by default in development. Auto-dismisses after 8 seconds; hover to keep open. Only appears on HTML 200 responses. Disable with `AndOne.dev_toast = false`.
18
+ - New `dev_toast` configuration option
19
+ - "Development UI" section in README documenting both the toast and dashboard features
20
+
3
21
  ## [0.1.0] - 2026-02-27
4
22
 
5
23
  - Initial release
data/README.md CHANGED
@@ -14,8 +14,9 @@ AndOne stays completely invisible until it detects an N+1 query — then it tell
14
14
  - **Auto-raises in test** — N+1s fail your test suite by default
15
15
  - **Background job support** — ActiveJob (`around_perform`) and Sidekiq server middleware, with double-scan protection
16
16
  - **Ignore file** — `.and_one_ignore` with `gem:`, `path:`, `query:`, and `fingerprint:` rules
17
- - **Aggregate mode** — report each unique N+1 once per server session with occurrence counts
17
+ - **Automatic deduplication** — each unique N+1 is reported once per server session with occurrence counts
18
18
  - **Test matchers** — Minitest (`assert_no_n_plus_one`) and RSpec (`expect { }.not_to cause_n_plus_one`)
19
+ - **Dev toast notifications** — in-page toast on every page that triggers an N+1, with a link to the dashboard
19
20
  - **Dev UI dashboard** — browse `/__and_one` in development for a live N+1 overview
20
21
  - **Rails console integration** — auto-scans in `rails console` and prints warnings inline
21
22
  - **Structured JSON logging** — JSON output mode for Datadog, Splunk, and other log aggregation services
@@ -131,14 +132,9 @@ This is especially useful for **N+1s coming from gems** where you can't add `.in
131
132
  | `query:some_table` | A specific query pattern should always be ignored |
132
133
  | `fingerprint:abc123` | You want to silence one specific detection (shown in output) |
133
134
 
134
- ## Aggregate Mode
135
+ ## Deduplication
135
136
 
136
- In development, the same N+1 can fire on every request, flooding your logs. Aggregate mode reports each unique pattern only once per server session:
137
-
138
- ```ruby
139
- # config/initializers/and_one.rb
140
- AndOne.aggregate_mode = true
141
- ```
137
+ In development, the same N+1 can fire on every request, flooding your logs. AndOne automatically deduplicates each unique pattern is reported only once per server session. Subsequent occurrences are silently counted.
142
138
 
143
139
  You can check the session summary at any time:
144
140
 
@@ -148,6 +144,29 @@ AndOne.aggregate.size # number of unique patterns
148
144
  AndOne.aggregate.reset! # clear and start fresh
149
145
  ```
150
146
 
147
+ ## Development UI
148
+
149
+ ### In-page toast notifications
150
+
151
+ When an N+1 is detected during a request, AndOne injects a small toast notification into the bottom-right corner of the page. The toast shows which tables were affected and links to the full dashboard for details.
152
+
153
+ This is enabled by default in development — no configuration needed. The toast auto-dismisses after 8 seconds, but hovering over it keeps it open.
154
+
155
+ To disable it:
156
+
157
+ ```ruby
158
+ # config/initializers/and_one.rb
159
+ AndOne.dev_toast = false
160
+ ```
161
+
162
+ The toast only appears on HTML responses with a 200 status, so it won't interfere with API endpoints, redirects, or error pages.
163
+
164
+ ### Dashboard
165
+
166
+ Browse `/__and_one` in development for a full overview of every unique N+1 detected in the current server session. The dashboard shows the query, origin, fix location, and suggested `.includes()` call for each detection.
167
+
168
+ Both features work together: the toast gives you immediate feedback on the page you're looking at, and the dashboard link takes you to the full picture.
169
+
151
170
  ## Test Matchers
152
171
 
153
172
  ### Minitest
@@ -214,8 +233,8 @@ AndOne.configure do |config|
214
233
  # Minimum repeated queries to trigger (default: 2)
215
234
  config.min_n_queries = 3
216
235
 
217
- # Aggregate mode only report each unique N+1 once per session
218
- config.aggregate_mode = true
236
+ # In-page toast notifications (default: true in development)
237
+ config.dev_toast = true
219
238
 
220
239
  # Path to ignore file (default: Rails.root/.and_one_ignore)
221
240
  config.ignore_file_path = Rails.root.join(".and_one_ignore").to_s
@@ -18,8 +18,8 @@ module AndOne
18
18
 
19
19
  private
20
20
 
21
- def and_one_scan
22
- and_one_wrap { yield }
21
+ def and_one_scan(&)
22
+ and_one_wrap(&)
23
23
  end
24
24
  end
25
25
  end
@@ -2,19 +2,16 @@
2
2
 
3
3
  module AndOne
4
4
  # Tracks unique N+1 detections across requests/jobs in a server session.
5
- # In aggregate mode, each unique N+1 (by fingerprint) is only reported once.
5
+ # Each unique N+1 (by fingerprint) is only reported once.
6
6
  # Subsequent occurrences are silently counted.
7
7
  #
8
- # Usage:
9
- # AndOne.aggregate_mode = true
10
- #
11
8
  # The aggregate can be queried at any time:
12
9
  # AndOne.aggregate.summary # => formatted string
13
10
  # AndOne.aggregate.detections # => { fingerprint => { detection:, count:, first_seen_at: } }
14
11
  # AndOne.aggregate.reset!
15
12
  #
16
13
  class Aggregate
17
- Entry = Struct.new(:detection, :occurrences, :first_seen_at, :last_seen_at, keyword_init: true)
14
+ Entry = Struct.new(:detection, :occurrences, :first_seen_at, :last_seen_at)
18
15
 
19
16
  def initialize
20
17
  @mutex = Mutex.new
@@ -65,19 +62,19 @@ module AndOne
65
62
 
66
63
  lines = []
67
64
  lines << ""
68
- lines << "🏀 AndOne Session Summary: #{@entries.size} unique N+1 pattern#{'s' if @entries.size != 1}"
69
- lines << "─" * 60
65
+ lines << "🏀 AndOne Session Summary: #{@entries.size} unique N+1 pattern#{"s" if @entries.size != 1}"
66
+ lines << ("─" * 60)
70
67
 
71
68
  @entries.each_with_index do |(fp, entry), i|
72
69
  det = entry.detection
73
- lines << " #{i + 1}) #{det.table_name || 'unknown'} — #{entry.occurrences} occurrence#{'s' if entry.occurrences != 1}"
70
+ lines << " #{i + 1}) #{det.table_name || "unknown"} — #{entry.occurrences} occurrence#{"s" if entry.occurrences != 1}"
74
71
  lines << " #{det.sample_query[0, 120]}"
75
72
  lines << " origin: #{det.origin_frame}" if det.origin_frame
76
73
  lines << " fingerprint: #{fp}"
77
74
  lines << ""
78
75
  end
79
76
 
80
- lines << "─" * 60
77
+ lines << ("─" * 60)
81
78
  lines.join("\n")
82
79
  end
83
80
  end
@@ -50,7 +50,7 @@ module AndOne
50
50
 
51
51
  model = ActiveRecord::Base.descendants.detect do |klass|
52
52
  klass.table_name == table_name
53
- rescue
53
+ rescue StandardError
54
54
  false
55
55
  end
56
56
 
@@ -82,11 +82,11 @@ module AndOne
82
82
 
83
83
  klass.reflect_on_all_associations.each do |assoc|
84
84
  matched = if effective_key
85
- association_matches?(assoc, target_model, effective_key)
86
- else
87
- # For through associations, foreign key may not be directly visible
88
- through_association_matches?(assoc, target_model)
89
- end
85
+ association_matches?(assoc, target_model, effective_key)
86
+ else
87
+ # For through associations, foreign key may not be directly visible
88
+ through_association_matches?(assoc, target_model)
89
+ end
90
90
 
91
91
  next unless matched
92
92
 
@@ -98,10 +98,10 @@ module AndOne
98
98
  fix_hint: build_fix_hint(klass, assoc.name),
99
99
  loading_strategy: strategy,
100
100
  is_through: assoc.is_a?(ActiveRecord::Reflection::ThroughReflection),
101
- is_polymorphic: assoc.respond_to?(:options) && !!assoc.options[:as]
101
+ is_polymorphic: assoc.respond_to?(:options) && !assoc.options[:as].nil?
102
102
  }
103
103
  end
104
- rescue
104
+ rescue StandardError
105
105
  next
106
106
  end
107
107
 
@@ -133,27 +133,25 @@ module AndOne
133
133
  end
134
134
 
135
135
  def association_matches?(assoc, target_model, foreign_key)
136
- begin
137
- case assoc
138
- when ActiveRecord::Reflection::ThroughReflection
139
- # has_many :through — check if the source association points to our target
140
- assoc.klass == target_model
141
- when ActiveRecord::Reflection::HasManyReflection,
142
- ActiveRecord::Reflection::HasOneReflection
143
- if assoc.options[:as]
144
- # Polymorphic: has_many :comments, as: :commentable
145
- # The foreign key is like "commentable_id" and there's a "commentable_type" column
146
- poly_fk = "#{assoc.options[:as]}_id"
147
- assoc.klass == target_model && poly_fk == foreign_key
148
- else
149
- assoc.klass == target_model && assoc.foreign_key.to_s == foreign_key
150
- end
136
+ case assoc
137
+ when ActiveRecord::Reflection::ThroughReflection
138
+ # has_many :through — check if the source association points to our target
139
+ assoc.klass == target_model
140
+ when ActiveRecord::Reflection::HasManyReflection,
141
+ ActiveRecord::Reflection::HasOneReflection
142
+ if assoc.options[:as]
143
+ # Polymorphic: has_many :comments, as: :commentable
144
+ # The foreign key is like "commentable_id" and there's a "commentable_type" column
145
+ poly_fk = "#{assoc.options[:as]}_id"
146
+ assoc.klass == target_model && poly_fk == foreign_key
151
147
  else
152
- false
148
+ assoc.klass == target_model && assoc.foreign_key.to_s == foreign_key
153
149
  end
154
- rescue NameError
150
+ else
155
151
  false
156
152
  end
153
+ rescue NameError
154
+ false
157
155
  end
158
156
 
159
157
  def build_fix_hint(parent_model, association_name)
@@ -161,13 +159,10 @@ module AndOne
161
159
  end
162
160
 
163
161
  # Determine the optimal loading strategy based on query patterns
164
- def loading_strategy(sql, association_name)
162
+ def loading_strategy(sql, _association_name)
165
163
  # If the query has WHERE conditions on the association table, eager_load
166
164
  # is better because it does a LEFT OUTER JOIN allowing WHERE filtering
167
- if sql =~ /\bWHERE\b/i && sql =~ /\bJOIN\b/i
168
- :eager_load
169
- elsif sql =~ /\bWHERE\b.*\b(?:AND|OR)\b/i
170
- # Complex WHERE — eager_load with JOIN is more efficient
165
+ if sql =~ /\bWHERE\b/i && (sql =~ /\bJOIN\b/i || sql =~ /\b(?:AND|OR)\b/i)
171
166
  :eager_load
172
167
  else
173
168
  # Default: preload is generally faster (separate queries, no JOIN overhead)
@@ -202,10 +197,10 @@ module AndOne
202
197
  return nil unless actionable? && @parent_model
203
198
 
204
199
  assoc_type = if @is_through
205
- "has_many :#{@association_name}, through: ..."
206
- else
207
- "has_many :#{@association_name}"
208
- end
200
+ "has_many :#{@association_name}, through: ..."
201
+ else
202
+ "has_many :#{@association_name}"
203
+ end
209
204
 
210
205
  "Or prevent at the model level: `#{assoc_type}, strict_loading: true` in #{@parent_model.name}"
211
206
  end
@@ -219,8 +214,6 @@ module AndOne
219
214
  "Consider `.eager_load(:#{@association_name})` instead — your query filters on the association, so a JOIN is more efficient"
220
215
  when :preload
221
216
  "Consider `.preload(:#{@association_name})` — separate queries avoid JOIN overhead for simple loading"
222
- else
223
- nil # :includes is the default, no extra hint needed
224
217
  end
225
218
  end
226
219
  end
@@ -49,7 +49,7 @@ module AndOne
49
49
 
50
50
  def finish_scan
51
51
  AndOne.finish if AndOne.scanning?
52
- rescue
52
+ rescue StandardError
53
53
  # Don't let cleanup errors interrupt the console
54
54
  nil
55
55
  end
@@ -79,9 +79,9 @@ module AndOne
79
79
 
80
80
  # IRB in Rails 7.1+ uses IRB::Context#evaluate with hooks
81
81
  # We hook into the SIGINT-safe eval output via an around_eval approach
82
- if defined?(::IRB::Context)
83
- ::IRB::Context.prepend(IrbContextPatch)
84
- end
82
+ return unless defined?(::IRB::Context)
83
+
84
+ ::IRB::Context.prepend(IrbContextPatch)
85
85
  end
86
86
 
87
87
  def remove_irb_hook
@@ -94,15 +94,13 @@ module AndOne
94
94
 
95
95
  @pry_hook_installed = true
96
96
 
97
- ::Pry.hooks.add_hook(:after_eval, :and_one_console) do |result, _pry|
97
+ ::Pry.hooks.add_hook(:after_eval, :and_one_console) do |_result, _pry|
98
98
  AndOne::Console.send(:cycle_scan) if AndOne::Console.active?
99
99
  end
100
100
  end
101
101
 
102
102
  def remove_pry_hook
103
- if defined?(::Pry) && ::Pry.hooks
104
- ::Pry.hooks.delete_hook(:after_eval, :and_one_console)
105
- end
103
+ ::Pry.hooks.delete_hook(:after_eval, :and_one_console) if defined?(::Pry) && ::Pry.hooks
106
104
  @pry_hook_installed = false
107
105
  end
108
106
 
@@ -119,7 +117,7 @@ module AndOne
119
117
  result = super
120
118
  AndOne::Console.send(:cycle_scan) if AndOne::Console.active?
121
119
  result
122
- rescue => e
120
+ rescue StandardError
123
121
  AndOne::Console.send(:cycle_scan) if AndOne::Console.active?
124
122
  raise
125
123
  end
@@ -55,9 +55,9 @@ module AndOne
55
55
  private
56
56
 
57
57
  def extract_table_name(sql)
58
- if sql =~ /\bFROM\s+["`]?(\w+)["`]?/i
59
- $1
60
- end
58
+ return unless sql =~ /\bFROM\s+["`]?(\w+)["`]?/i
59
+
60
+ ::Regexp.last_match(1)
61
61
  end
62
62
 
63
63
  def find_origin_frame
@@ -5,8 +5,8 @@ module AndOne
5
5
  # Each instance tracks queries for a single request/block scope.
6
6
  class Detector
7
7
  DEFAULT_ALLOW_LIST = [
8
- /active_record\/relation.*preload_associations/,
9
- /active_record\/validations\/uniqueness/
8
+ %r{active_record/relation.*preload_associations},
9
+ %r{active_record/validations/uniqueness}
10
10
  ].freeze
11
11
 
12
12
  attr_reader :detections
@@ -58,7 +58,7 @@ module AndOne
58
58
  name = payload[:name]
59
59
 
60
60
  next if name == "SCHEMA"
61
- next if !sql.include?("SELECT")
61
+ next unless sql.include?("SELECT")
62
62
  next if payload[:cached]
63
63
  next if current_detector.send(:ignored?, sql)
64
64
 
@@ -79,13 +79,13 @@ module AndOne
79
79
  @query_holder[location_key] << sql
80
80
 
81
81
  # Only store caller on the second occurrence to save memory
82
- if @query_counter[location_key] >= 2
83
- @query_callers[location_key] = locations
84
- @query_metadata[location_key] ||= {
85
- connection_adapter: adapter_name,
86
- type_casted_binds: payload[:type_casted_binds]
87
- }
88
- end
82
+ return unless @query_counter[location_key] >= 2
83
+
84
+ @query_callers[location_key] = locations
85
+ @query_metadata[location_key] ||= {
86
+ connection_adapter: adapter_name,
87
+ type_casted_binds: payload[:type_casted_binds]
88
+ }
89
89
  end
90
90
 
91
91
  def location_fingerprint(locations)
@@ -99,7 +99,7 @@ module AndOne
99
99
 
100
100
  def adapter_name
101
101
  ActiveRecord::Base.connection_db_config.adapter
102
- rescue
102
+ rescue StandardError
103
103
  "unknown"
104
104
  end
105
105
 
@@ -135,7 +135,7 @@ module AndOne
135
135
  end
136
136
 
137
137
  def ignored?(sql)
138
- @ignore_queries.any? { |pattern| pattern === sql }
138
+ @ignore_queries.any? { |pattern| pattern.match?(sql) }
139
139
  end
140
140
  end
141
141
  end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AndOne
4
+ # Injects a small toast notification into HTML responses when N+1 queries
5
+ # are detected during the request. Links to the DevUI dashboard for details.
6
+ #
7
+ # Activated automatically by the Railtie in development when `dev_toast` is on,
8
+ # or can be enabled manually:
9
+ # AndOne.dev_toast = true
10
+ #
11
+ # The toast appears as a fixed-position badge in the bottom-right corner
12
+ # and auto-dismisses after 8 seconds (click to keep open).
13
+ module DevToast
14
+ module_function
15
+
16
+ # Injects toast HTML/JS/CSS before </body> in an HTML response.
17
+ # Returns the modified body string, or the original if not injectable.
18
+ def inject(body_string, detections)
19
+ return body_string if detections.nil? || detections.empty?
20
+ return body_string unless body_string.include?("</body>")
21
+
22
+ toast_html = render_toast(detections)
23
+ body_string.sub("</body>", "#{toast_html}\n</body>")
24
+ end
25
+
26
+ def render_toast(detections)
27
+ count = detections.size
28
+ label = "N+1 quer#{count == 1 ? "y" : "ies"}"
29
+
30
+ summaries = detections.map do |d|
31
+ table = escape(d.table_name || "unknown")
32
+ "#{d.count}x <code>#{table}</code>"
33
+ end.first(5)
34
+
35
+ extra = count > 5 ? "<div class=\"and-one-toast-extra\">...and #{count - 5} more</div>" : ""
36
+
37
+ <<~HTML
38
+ <div id="and-one-toast" class="and-one-toast" role="status" aria-live="polite">
39
+ <div class="and-one-toast-header">
40
+ <span class="and-one-toast-icon">🏀</span>
41
+ <strong>AndOne:</strong> #{count} #{label} detected
42
+ <button class="and-one-toast-close" onclick="document.getElementById('and-one-toast').remove()" aria-label="Dismiss">&times;</button>
43
+ </div>
44
+ <div class="and-one-toast-body">
45
+ #{summaries.join("<br>")}
46
+ #{extra}
47
+ </div>
48
+ <a class="and-one-toast-link" href="#{DevUI::MOUNT_PATH}">View Dashboard →</a>
49
+ </div>
50
+ <style>
51
+ .and-one-toast {
52
+ position: fixed;
53
+ bottom: 1rem;
54
+ right: 1rem;
55
+ z-index: 999999;
56
+ background: #1a1a2e;
57
+ color: #e0e0e0;
58
+ border: 2px solid #ff6b6b;
59
+ border-radius: 8px;
60
+ padding: 0;
61
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
62
+ font-size: 13px;
63
+ max-width: 380px;
64
+ min-width: 260px;
65
+ box-shadow: 0 4px 24px rgba(0,0,0,0.4);
66
+ animation: and-one-slide-in 0.3s ease-out;
67
+ transition: opacity 0.3s ease;
68
+ }
69
+ .and-one-toast-header {
70
+ display: flex;
71
+ align-items: center;
72
+ gap: 0.4rem;
73
+ padding: 0.5rem 0.75rem;
74
+ background: #16213e;
75
+ border-radius: 6px 6px 0 0;
76
+ border-bottom: 1px solid #2a2a4a;
77
+ color: #ff6b6b;
78
+ font-size: 13px;
79
+ }
80
+ .and-one-toast-icon { font-size: 16px; }
81
+ .and-one-toast-close {
82
+ margin-left: auto;
83
+ background: none;
84
+ border: none;
85
+ color: #888;
86
+ font-size: 18px;
87
+ cursor: pointer;
88
+ padding: 0 0.25rem;
89
+ line-height: 1;
90
+ }
91
+ .and-one-toast-close:hover { color: #ff6b6b; }
92
+ .and-one-toast-body {
93
+ padding: 0.5rem 0.75rem;
94
+ line-height: 1.5;
95
+ color: #ccc;
96
+ }
97
+ .and-one-toast-body code {
98
+ background: #16213e;
99
+ padding: 0.1rem 0.3rem;
100
+ border-radius: 3px;
101
+ font-size: 12px;
102
+ color: #ffd93d;
103
+ }
104
+ .and-one-toast-extra {
105
+ color: #888;
106
+ font-size: 12px;
107
+ margin-top: 0.25rem;
108
+ }
109
+ .and-one-toast-link {
110
+ display: block;
111
+ padding: 0.5rem 0.75rem;
112
+ color: #a8d8ea;
113
+ text-decoration: none;
114
+ font-size: 12px;
115
+ border-top: 1px solid #2a2a4a;
116
+ }
117
+ .and-one-toast-link:hover {
118
+ background: #16213e;
119
+ border-radius: 0 0 6px 6px;
120
+ text-decoration: underline;
121
+ }
122
+ @keyframes and-one-slide-in {
123
+ from { transform: translateY(1rem); opacity: 0; }
124
+ to { transform: translateY(0); opacity: 1; }
125
+ }
126
+ </style>
127
+ <script>
128
+ (function() {
129
+ var toast = document.getElementById('and-one-toast');
130
+ if (!toast) return;
131
+ var timer = setTimeout(function() {
132
+ toast.style.opacity = '0';
133
+ setTimeout(function() { toast.remove(); }, 300);
134
+ }, 8000);
135
+ toast.addEventListener('mouseenter', function() { clearTimeout(timer); });
136
+ })();
137
+ </script>
138
+ HTML
139
+ end
140
+
141
+ def escape(text)
142
+ text.to_s
143
+ .gsub("&", "&amp;")
144
+ .gsub("<", "&lt;")
145
+ .gsub(">", "&gt;")
146
+ .gsub('"', "&quot;")
147
+ end
148
+ end
149
+ end