storyteller 0.0.2

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 (38) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +202 -0
  3. data/README.md +72 -0
  4. data/lib/story_teller/book.rb +39 -0
  5. data/lib/story_teller/chapter.rb +44 -0
  6. data/lib/story_teller/configurable.rb +21 -0
  7. data/lib/story_teller/console.rb +7 -0
  8. data/lib/story_teller/environments/development/configuration.rb +3 -0
  9. data/lib/story_teller/environments/development/middleware.rb +12 -0
  10. data/lib/story_teller/environments/development/notifications.rb +286 -0
  11. data/lib/story_teller/environments/development/rack.rb +35 -0
  12. data/lib/story_teller/environments/development/sidekiq.rb +33 -0
  13. data/lib/story_teller/environments/development.rb +47 -0
  14. data/lib/story_teller/environments/production/configuration.rb +4 -0
  15. data/lib/story_teller/environments/production/middleware.rb +13 -0
  16. data/lib/story_teller/environments/production/rack.rb +36 -0
  17. data/lib/story_teller/environments/production.rb +6 -0
  18. data/lib/story_teller/environments/staging/configuration.rb +4 -0
  19. data/lib/story_teller/environments/staging.rb +5 -0
  20. data/lib/story_teller/environments/test/configuration.rb +4 -0
  21. data/lib/story_teller/environments/test.rb +4 -0
  22. data/lib/story_teller/environments.rb +4 -0
  23. data/lib/story_teller/exception.rb +23 -0
  24. data/lib/story_teller/formatters/development/error.rb +52 -0
  25. data/lib/story_teller/formatters/development/info.rb +85 -0
  26. data/lib/story_teller/formatters/development.rb +4 -0
  27. data/lib/story_teller/formatters/null.rb +3 -0
  28. data/lib/story_teller/formatters/structured.rb +8 -0
  29. data/lib/story_teller/formatters.rb +26 -0
  30. data/lib/story_teller/levels.rb +4 -0
  31. data/lib/story_teller/logger.rb +62 -0
  32. data/lib/story_teller/message.rb +35 -0
  33. data/lib/story_teller/railtie.rb +73 -0
  34. data/lib/story_teller/story.rb +26 -0
  35. data/lib/story_teller/version.rb +13 -0
  36. data/lib/story_teller.rb +76 -0
  37. data/storyteller.gemspec +19 -0
  38. metadata +81 -0
@@ -0,0 +1,286 @@
1
+ module StoryTeller::Notifications
2
+ INTERNAL_PARAMS = %w(controller action format _method only_path)
3
+ VIEWS_PATTERN = /^\/app\/views\//
4
+
5
+ ActiveSupport::Notifications.subscribe("process_action.action_controller") do |event|
6
+ payload = event.payload
7
+ additions = ::ActionController::Base.log_process_action(payload)
8
+ status = payload[:status]
9
+
10
+ if status.nil? && (exception_class_name = payload[:exception]&.first)
11
+ status = ::ActionDispatch::ExceptionWrapper.status_code_for_exception(exception_class_name)
12
+ end
13
+
14
+ color_code = if status.to_i > 399
15
+ "\e[1;31m%{status}\e[0m"
16
+ else
17
+ "\e[1m%{status}\e[0m"
18
+ end
19
+
20
+ StoryTeller.info(
21
+ "Response: #{color_code} - %{status_code} in %{duration}ms.",
22
+ controller: payload[:controller],
23
+ action: payload[:action],
24
+ allocations: event.allocations,
25
+ status_code: status,
26
+ status: Rack::Utils::HTTP_STATUS_CODES[status],
27
+ duration: event.duration.round,
28
+ duration_explained: additions.join(" | ")
29
+ )
30
+
31
+ # This newline output is to separate logs from one action to another.
32
+ # it is only useful in dev to give the user an easier time to browse through
33
+ # their log
34
+ STDOUT << "\n"
35
+ end
36
+
37
+ ActiveSupport::Notifications.subscribe("start_processing.action_controller") do |event|
38
+ payload = event.payload
39
+ params = payload[:params].except(*INTERNAL_PARAMS)
40
+ format = payload[:format]
41
+ format = format.to_s.upcase if format.is_a?(Symbol)
42
+ format = "*/*" if format.nil?
43
+
44
+ StoryTeller.info("Requested %{controller}#%{action} as %{format}",
45
+ controller: payload[:controller],
46
+ action: payload[:action],
47
+ format: format
48
+ )
49
+ StoryTeller.info("Parameters: %{params}", params: params) unless params.empty?
50
+ end
51
+
52
+ ActiveSupport::Notifications.subscribe("render_template.action_view") do |event|
53
+ path = event.payload[:identifier]
54
+ if !path.starts_with?(Rails.root.to_s)
55
+ next
56
+ end
57
+
58
+ path = path.sub(Rails.root.to_s, "").sub(VIEWS_PATTERN, "")
59
+ StoryTeller.info("Rendered template %{path} in %{duration}ms",
60
+ path: path,
61
+ duration: event.duration.round(1)
62
+ )
63
+ end
64
+
65
+ ActiveSupport::Notifications.subscribe("render_partial.action_view") do |event|
66
+ path = event.payload[:identifier]
67
+ if !path.starts_with?(Rails.root.to_s)
68
+ next
69
+ end
70
+
71
+ path = path.sub(Rails.root.to_s, "").sub(VIEWS_PATTERN, "")
72
+ StoryTeller.info("Rendered partial %{path} in %{duration}ms",
73
+ path: path,
74
+ duration: event.duration.round(1)
75
+ )
76
+ end
77
+
78
+ ActiveSupport::Notifications.subscribe("render_collection.action_view") do |event|
79
+ path = event.payload[:identifier]
80
+ if !path.starts_with?(Rails.root.to_s)
81
+ next
82
+ end
83
+
84
+ path = path.sub(Rails.root.to_s, "").sub(VIEWS_PATTERN, "")
85
+ StoryTeller.info("Rendered collection of %{size} for %{path} in %{duration}ms",
86
+ path: path,
87
+ size: event.payload,
88
+ duration: event.duration.round(1)
89
+ )
90
+ end
91
+
92
+ ActiveSupport::Notifications.subscribe("render_layout.action_view") do |event|
93
+ path = event.payload[:identifier]
94
+ if !path.starts_with?(Rails.root.to_s)
95
+ next
96
+ end
97
+
98
+ path = path.sub(Rails.root.to_s, "").sub(VIEWS_PATTERN, "")
99
+ StoryTeller.info("Rendered layout %{path} in %{duration}ms",
100
+ path: path,
101
+ duration: event.duration.round(1)
102
+ )
103
+ end
104
+
105
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
106
+ payload = event.payload
107
+
108
+ if payload[:binds]&.any?
109
+ StoryTeller.info("%{name} (%{duration}ms) %{sql}",
110
+ name: payload[:name],
111
+ duration: event.duration.round(1),
112
+ sql: payload[:sql],
113
+ )
114
+ end
115
+ end
116
+
117
+ ActiveSupport::Notifications.subscribe("deliver.action_mailer") do |event|
118
+ perform_deliveries = event.payload[:perform_deliveries]
119
+ if perform_deliveries
120
+ StoryTeller.info("Delivered mail #{event.payload[:message_id]} (#{event.duration.round(1)}ms)")
121
+ else
122
+ StoryTeller.info("Skipped delivery of mail #{event.payload[:message_id]} as `perform_deliveries` is false")
123
+ end
124
+ end
125
+
126
+ ActiveSupport::Notifications.subscribe("render.action_mailer") do |event|
127
+ mailer = event.payload[:mailer]
128
+ action = event.payload[:action]
129
+ StoryTeller.info("#{mailer}##{action}: processed outbound mail in #{event.duration.round(1)}ms")
130
+ end
131
+
132
+ ActiveSupport::Notifications.subscribe("service_upload.active_storage") do |event|
133
+ message = "Uploaded file to key: #{event.payload[:key]}"
134
+ message += " (checksum: #{event.payload[:checksum]})" if event.payload[:checksum]
135
+
136
+ StoryTeller.info "[#{event.payload[:service]} Storage] #{message}", duration: event.duration.round(1)
137
+ end
138
+
139
+ ActiveSupport::Notifications.subscribe("service_download.active_storage") do |event|
140
+ StoryTeller.info "[#{event.payload[:service]} Storage] Downloaded file from key: #{event.payload[:key]}"
141
+ end
142
+
143
+ ActiveSupport::Notifications.subscribe("service_streaming_download.active_storage") do |event|
144
+ StoryTeller.info "[#{event.payload[:service]} Storage] Downloaded file from key: #{event.payload[:key]}"
145
+ end
146
+
147
+ ActiveSupport::Notifications.subscribe("service_delete.active_storage") do |event|
148
+ StoryTeller.info "[#{event.payload[:service]} Storage] Deleted file from key: #{event.payload[:key]}"
149
+ end
150
+
151
+ ActiveSupport::Notifications.subscribe("service_delete_prefixed.active_storage") do |event|
152
+ StoryTeller.info "[#{event.payload[:service]} Storage] Deleted files by key prefix: #{event.payload[:prefix]}"
153
+ end
154
+
155
+ ActiveSupport::Notifications.subscribe("service_exist.active_storage") do |event|
156
+ StoryTeller.info "[#{event.payload[:service]} Storage] Checked if file exists at key: #{event.payload[:key]} (#{event.payload[:exist] ? "yes" : "no"})"
157
+ end
158
+
159
+ ActiveSupport::Notifications.subscribe("service_url.active_storage") do |event|
160
+ StoryTeller.info "[#{event.payload[:service]} Storage] Generated URL for file at key: #{event.payload[:key]} (#{event.payload[:url]})"
161
+ end
162
+
163
+ ActiveSupport::Notifications.subscribe("service_mirror.active_storage") do |event|
164
+ message = "Mirrored file at key: #{event.payload[:key]}"
165
+ message += " (checksum: #{event.payload[:checksum]})" if event.payload[:checksum]
166
+
167
+ StoryTeller.info "[#{event.payload[:service]} Storage] #{message}"
168
+ end
169
+
170
+ ActiveSupport::Notifications.subscribe("enqueue.active_job") do |event|
171
+ job = event.payload[:job]
172
+ exception = event.payload[:exception_object]
173
+ queue_name = event.payload[:adapter].class.name.demodulize.remove("Adapter") + "(#{event.payload[:job].queue_name})"
174
+
175
+ if exception
176
+ StoryTeller.info(
177
+ "Failed enqueuing %{job_class} to %{queue_name}",
178
+ job_class: job.class.name,
179
+ queue_name: queue_name
180
+ )
181
+ StoryTeller.error(ex)
182
+ elsif event.payload[:aborted]
183
+ StoryTeller.info(
184
+ "Failed enqueuing %{job_name} to %{queue_name}, a before_enqueue callback halted the enqueuing execution.",
185
+ job_class: job.class.name,
186
+ queue_name: queue_name
187
+ )
188
+ else
189
+ StoryTeller.info(
190
+ "Enqueued %{job_name} (Job ID: %{job_id}) to %{queue_name}",
191
+ job_id: job.job_id,
192
+ job_class: job.class.name,
193
+ queue_name: queue_name
194
+ )
195
+ end
196
+ end
197
+
198
+ ActiveSupport::Notifications.subscribe("enqueue_at.active_job") do |event|
199
+ job = event.payload[:job]
200
+ ex = event.payload[:exception_object]
201
+ queue_name = event.payload[:adapter].class.name.demodulize.remove("Adapter") + "(#{event.payload[:job].queue_name})"
202
+
203
+ if ex
204
+ StoryTeller.info(
205
+ "Failed enqueuing %{job_class} to %{queue_name}",
206
+ job_class: job.class.name,
207
+ queue_name: queue_name
208
+ )
209
+ StoryTeller.error(StoryTeller::Exception.new(exception: ex))
210
+ elsif event.payload[:aborted]
211
+ StoryTeller.info(
212
+ "Failed enqueuing %{job_class} to %{queue_name}, a before_enqueue callback halted the enqueuing execution.",
213
+ job_class: job.class.name,
214
+ queue_name: queue_name
215
+ )
216
+ else
217
+ StoryTeller.info(
218
+ "Enqueued %{job.class.name} (Job ID: %{job_id}) to %{queue_name} at %{scheduled_at}",
219
+ job_class: job.class.name,
220
+ job_id: job.job_id,
221
+ queue_name: queue_name,
222
+ scheduled_at: Time.at(event.payload[:job].scheduled_at).utc
223
+ )
224
+ end
225
+ end
226
+
227
+ ActiveSupport::Notifications.subscribe("perform_start.active_job") do |event|
228
+ job = event.payload[:job]
229
+ StoryTeller.info(
230
+ "Performing %{job_class} (Job ID: %{job_id}) from %{queue_name} enqueued at %{enqueued_at}",
231
+ job_class: job.class.name,
232
+ job_id: job.job_id,
233
+ queue_name: queue_name,
234
+ enqueued_at: job.enqueued_at
235
+ )
236
+ end
237
+
238
+ ActiveSupport::Notifications.subscribe("perform.active_job") do |event|
239
+ job = event.payload[:job]
240
+ ex = event.payload[:exception_object]
241
+ queue_name = event.payload[:adapter].class.name.demodulize.remove("Adapter") + "(#{event.payload[:job].queue_name})"
242
+ if ex
243
+ StoryTeller.info("Error performing #{job.class.name} (Job ID: #{job.job_id}) from #{queue_name} in #{event.duration.round(2)}ms")
244
+ StoryTeller.error(StoryTeller::Exception.new(ex))
245
+ elsif event.payload[:aborted]
246
+ StoryTeller.info("Error performing #{job.class.name} (Job ID: #{job.job_id}) from #{queue_name} in #{event.duration.round(2)}ms: a before_perform callback halted the job execution")
247
+ else
248
+ StoryTeller.info("Performed #{job.class.name} (Job ID: #{job.job_id}) from #{queue_name} in #{event.duration.round(2)}ms")
249
+ end
250
+ end
251
+
252
+ ActiveSupport::Notifications.subscribe("enqueue_retry.active_job") do |event|
253
+ job = event.payload[:job]
254
+ ex = event.payload[:error]
255
+ wait = event.payload[:wait]
256
+
257
+ if ex
258
+ StoryTeller.info(
259
+ "Retrying %{job_class} in %{wait} seconds, due to a %{exception_class}",
260
+ job_class: job.class,
261
+ wait: wait.to_i,
262
+ exception_class: ex.class
263
+ )
264
+ else
265
+ StoryTeller.info(
266
+ "Retrying %{job_class} in %{wait} seconds",
267
+ job_class: job.class,
268
+ wait: wait.to_i
269
+ )
270
+ end
271
+ end
272
+
273
+ ActiveSupport::Notifications.subscribe("retry_stopped.active_job") do |event|
274
+ job = event.payload[:job]
275
+ ex = event.payload[:error]
276
+
277
+ StoryTeller.error(StoryTeller::Exception(exception: ex))
278
+ end
279
+
280
+ ActiveSupport::Notifications.subscribe("discard.active_job") do |event|
281
+ job = event.payload[:job]
282
+ ex = event.payload[:error]
283
+
284
+ StoryTeller.error(StoryTeller::Exception(exception: ex))
285
+ end
286
+ end
@@ -0,0 +1,35 @@
1
+ class StoryTeller::Rack < ::ActionDispatch::DebugExceptions
2
+ def call(env)
3
+ request_id = request_id(env)
4
+ request = ActionDispatch::Request.new(env)
5
+ StoryTeller.chapter(title: "path", subject: request.path) do |chapter|
6
+ if request_id.present?
7
+ chapter.attributes["request_id"] = request_id
8
+ end
9
+
10
+ _, headers, body = response = @app.call(env)
11
+ if headers["X-Cascade"] == "pass"
12
+ body.close if body.respond_to?(:close)
13
+ raise ActionController::RoutingError, "No route matches [#{env['REQUEST_METHOD']}] #{env['PATH_INFO'].inspect}"
14
+ end
15
+
16
+ response
17
+ end
18
+ rescue Exception => exception
19
+ invoke_interceptors(request, exception)
20
+ raise exception unless request.show_exceptions?
21
+ render_exception(request, exception)
22
+ ensure
23
+ ActiveSupport::LogSubscriber.flush_all!
24
+ StoryTeller::Book.clear!
25
+ end
26
+
27
+ private
28
+
29
+ # No-op with StoryTeller. The error is already logged in the chapter defined.
30
+ def log_error(request, wrapper);end
31
+
32
+ def request_id(env)
33
+ env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"]
34
+ end
35
+ end
@@ -0,0 +1,33 @@
1
+ module StoryTeller::Middlewares
2
+ module Sidekiq
3
+ class Client
4
+ include ::Sidekiq::ClientMiddleware
5
+
6
+ def call(worker, job, queue, redis_pool)
7
+ chapter = StoryTeller.book.current_chapter
8
+ if chapter.attributes.key?(:request_id)
9
+ msg[:request_id] = chapter.attributes[:request_id]
10
+ end
11
+
12
+ yield
13
+ end
14
+ end
15
+
16
+ class Server
17
+ include ::Sidekiq::ServerMiddleware
18
+
19
+ def call(worker, job, queue)
20
+ chapter = StoryTeller.book.current_chapter
21
+ if job.key?(:request_id)
22
+ chapter.attributes[:request_id] = job[:request_id]
23
+ end
24
+
25
+ chapter[:job] = job.class
26
+
27
+ yield
28
+ ensure
29
+ StoryTeller::Book.clear!
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,47 @@
1
+ require "story_teller/environments/development/configuration"
2
+ require "story_teller/environments/development/middleware"
3
+ require "story_teller/environments/development/notifications"
4
+ require "story_teller/environments/development/railties"
5
+
6
+ module StoryTeller::Environments::Development
7
+ def self.included(mod)
8
+ StoryTeller::Chapter.prepend(Module.new do
9
+ attr_accessor :expand_attributes
10
+ def merge(parent)
11
+ if parent.expand_attributes || StoryTeller::Book.current_book.expand_attributes
12
+ self.expand_attributes = true
13
+ end
14
+
15
+ @attributes = parent.attributes.merge(attributes)
16
+ end
17
+
18
+ def expand_attributes
19
+ @expand_attributes || false
20
+ end
21
+ end)
22
+
23
+ StoryTeller::Book.prepend(Module.new do
24
+ attr_accessor :expand_attributes
25
+
26
+ def expand_attributes
27
+ @expand_attributes || false
28
+ end
29
+ end)
30
+
31
+ mod.extend(Module.new do
32
+ def expand_attributes!
33
+ book = StoryTeller::Book.current_book
34
+ chapter = book.current_chapter
35
+ if chapter
36
+ chapter.expand_attributes = true
37
+ else
38
+ book.expand_attributes = true
39
+ end
40
+ end
41
+ end)
42
+ end
43
+ end
44
+
45
+ module StoryTeller
46
+ require "story_teller/environments/development/rack"
47
+ end
@@ -0,0 +1,4 @@
1
+ class StoryTeller::Configuration
2
+ include StoryTeller::Configurable
3
+ end
4
+
@@ -0,0 +1,13 @@
1
+ class StoryTeller::Middleware
2
+ def initialize(app)
3
+ @app = app
4
+ end
5
+
6
+ def call(env)
7
+ req = ::ActionDispatch::Request.new(env)
8
+ StoryTeller.chapter(title: req.params[:controller], subject: req.params[:action]) do
9
+ @app.call(env)
10
+ end
11
+ end
12
+ end
13
+
@@ -0,0 +1,36 @@
1
+ class StoryTeller::Rack < ::ActionDispatch::DebugExceptions
2
+ def call(env)
3
+ request_id = request_id(env)
4
+ request = ActionDispatch::Request.new(env)
5
+ StoryTeller.chapter(title: "path", subject: request.path) do |chapter|
6
+ if request_id.present?
7
+ chapter.attributes["request_id"] = request_id
8
+ end
9
+
10
+ _, headers, body = response = @app.call(env)
11
+ if headers["X-Cascade"] == "pass"
12
+ body.close if body.respond_to?(:close)
13
+ raise ActionController::RoutingError, "No route matches [#{env['REQUEST_METHOD']}] #{env['PATH_INFO'].inspect}"
14
+ end
15
+
16
+ response
17
+ end
18
+ rescue Exception => exception
19
+ invoke_interceptors(request, exception)
20
+ raise exception unless request.show_exceptions?
21
+ render_exception(request, exception)
22
+ ensure
23
+ ActiveSupport::LogSubscriber.flush_all!
24
+ StoryTeller::Book.clear!
25
+ end
26
+
27
+ private
28
+
29
+ # No-op with StoryTeller. The error is already logged in the chapter defined.
30
+ def log_error(request, wrapper);end
31
+
32
+ def request_id(env)
33
+ env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"]
34
+ end
35
+ end
36
+
@@ -0,0 +1,6 @@
1
+ require "story_teller/environments/production/configuration"
2
+ require "story_teller/environments/production/middleware"
3
+ require "story_teller/environments/production/rack"
4
+
5
+ module StoryTeller::Environments::Production
6
+ end
@@ -0,0 +1,4 @@
1
+ class StoryTeller::Configuration
2
+ include StoryTeller::Configurable
3
+ end
4
+
@@ -0,0 +1,5 @@
1
+ require "story_teller/environments/staging/configuration"
2
+
3
+ module StoryTeller::Environments::Staging
4
+ end
5
+
@@ -0,0 +1,4 @@
1
+ class StoryTeller::Configuration
2
+ include StoryTeller::Configurable
3
+ end
4
+
@@ -0,0 +1,4 @@
1
+ require "story_teller/environments/test/configuration"
2
+
3
+ module StoryTeller::Environments::Test
4
+ end
@@ -0,0 +1,4 @@
1
+ module StoryTeller::Environments
2
+ autoload :Development, "story_teller/environments/development"
3
+ autoload :Production, "story_teller/environments/production"
4
+ end
@@ -0,0 +1,23 @@
1
+ class StoryTeller::Exception
2
+ attr_reader :chapter, :exception, :timestamp
3
+
4
+ def initialize(exception:, chapter: nil)
5
+ self.timestamp = Time.now.utc
6
+ self.exception = exception
7
+ self.chapter = chapter
8
+ end
9
+
10
+ def to_hash
11
+ {
12
+ timestamp: timestamp.strftime("%s%N"),
13
+ message: exception.description,
14
+ data: {
15
+ story: exception.backtrace,
16
+ chapter: chapter&.attributes
17
+ }
18
+ }
19
+ end
20
+
21
+ private
22
+ attr_writer :chapter, :exception, :timestamp
23
+ end
@@ -0,0 +1,52 @@
1
+ class StoryTeller::Formatters::Development::Error < StoryTeller::Formatters::Base
2
+ COLORS = {
3
+ default: 0,
4
+ black: 30,
5
+ red: 31,
6
+ green: 32,
7
+ yellow: 33,
8
+ blue: 34,
9
+ magenta: 35,
10
+ cyan: 36,
11
+ light_gray: 37,
12
+ light_grey: 37,
13
+ gray: 90,
14
+ grey: 90,
15
+ light_red: 91,
16
+ light_yellow: 93,
17
+ light_blue: 95,
18
+ light_magenta: 94,
19
+ light_cyan: 96,
20
+ white: 97
21
+ }.freeze
22
+
23
+ def config
24
+ @backtrace_cleaner = Rails.backtrace_cleaner
25
+ @colors = {
26
+ message: COLORS[:red],
27
+ trace: "1;#{COLORS[:red]}"
28
+ }
29
+ end
30
+
31
+
32
+ def write(story)
33
+ exception = story.exception
34
+ output << color("#{exception.class} (#{exception.message})", :message) << "\n"
35
+
36
+ trace = @backtrace_cleaner.clean(exception.backtrace, :noise).reverse
37
+ trace[-1] = color(trace.last, :trace)
38
+
39
+ output << "Backtrace: \t" << trace.join("\n\t\t") << "\n"
40
+
41
+ if output.respond_to?(:flush)
42
+ output.flush
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def color(text, type)
49
+ "\e[1;#{@colors[type]}m#{text}\e[0m"
50
+ end
51
+
52
+ end
@@ -0,0 +1,85 @@
1
+ class StoryTeller::Formatters::Development::Info < StoryTeller::Formatters::Base
2
+ COLORS = {
3
+ default: 0,
4
+ black: 30,
5
+ red: 31,
6
+ green: 32,
7
+ yellow: 33,
8
+ blue: 34,
9
+ magenta: 35,
10
+ cyan: 36,
11
+ light_gray: 37,
12
+ light_grey: 37,
13
+ gray: 90,
14
+ grey: 90,
15
+ light_red: 91,
16
+ light_yellow: 93,
17
+ light_blue: 95,
18
+ light_magenta: 94,
19
+ light_cyan: 96,
20
+ white: 97
21
+ }.freeze
22
+
23
+ def config
24
+ @colors = {
25
+ key: COLORS[:blue],
26
+ value: COLORS[:cyan],
27
+ chapter: COLORS[:green],
28
+ }
29
+ end
30
+
31
+ def write(story)
32
+ data = story.to_hash
33
+ attributes = expand(**data[:data])
34
+
35
+ chapter = if data[:data][:chapter].present?
36
+ data[:data][:chapter].map do |k, v|
37
+ "#{k.to_s}=#{v.to_s}"
38
+ end
39
+ else
40
+ []
41
+ end
42
+
43
+ output << "[" << color(chapter.join(" "), :chapter) << "] " if chapter.any?
44
+
45
+ # Might want to allow this to be configurable by the user which could then
46
+ # use the color(value, style) method
47
+ if story.attributes[:level] == :error
48
+ output << "\e[1;#{COLORS[:red]}m" << data[:message] << "\e[0;#{COLORS[:default]}m"
49
+ else
50
+ output << data[:message]
51
+ end
52
+
53
+ if attributes.size > 0
54
+ if story.chapter&.expand_attributes || StoryTeller::Book.current_book.expand_attributes
55
+ output << "\n\t" << attributes.join("\n\t")
56
+ else
57
+ output << color(" [#{attributes.size} Attribute#{attributes.size > 1 ? "s" : ""}] ", :key)
58
+ end
59
+ end
60
+
61
+ output << "\n"
62
+
63
+ if output.respond_to?(:flush)
64
+ output.flush
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def color(text, type)
71
+ "\e[#{@colors[type]}m#{text}\e[0m"
72
+ end
73
+
74
+ def expand(**data)
75
+ elements = []
76
+
77
+ if data[:story].present? && data[:story].any?
78
+ data[:story].each do |k, v|
79
+ elements.push("#{color(k.to_s, :key)}: #{color(v.to_s, :value)}")
80
+ end
81
+ end
82
+
83
+ elements
84
+ end
85
+ end
@@ -0,0 +1,4 @@
1
+ module StoryTeller::Formatters::Development
2
+ autoload :Error, "story_teller/formatters/development/error"
3
+ autoload :Info, "story_teller/formatters/development/info"
4
+ end
@@ -0,0 +1,3 @@
1
+ class StoryTeller::Formatters::Null < StoryTeller::Formatters::Base
2
+ def write(story); end
3
+ end
@@ -0,0 +1,8 @@
1
+ class StoryTeller::Outputs::Structured < StoryTeller::Outputs::Base
2
+ def write(story)
3
+ output << story.to_json << "\n"
4
+ if output.respond_to?(:flush)
5
+ output.flush
6
+ end
7
+ end
8
+ end