rails-markup 1.2.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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +304 -0
  4. data/app/assets/javascripts/rails_markup/toolbar.js +2225 -0
  5. data/app/controllers/rails_markup/annotations_controller.rb +244 -0
  6. data/app/controllers/rails_markup/application_controller.rb +7 -0
  7. data/app/controllers/rails_markup/dashboard_controller.rb +230 -0
  8. data/app/controllers/rails_markup/external/annotations_controller.rb +68 -0
  9. data/app/models/rails_markup/annotation.rb +176 -0
  10. data/app/views/layouts/rails_markup/application.html.erb +158 -0
  11. data/app/views/rails_markup/dashboard/_annotation_card.html.erb +25 -0
  12. data/app/views/rails_markup/dashboard/_annotation_list.html.erb +57 -0
  13. data/app/views/rails_markup/dashboard/_annotation_page.html.erb +13 -0
  14. data/app/views/rails_markup/dashboard/_filters.html.erb +75 -0
  15. data/app/views/rails_markup/dashboard/_stats.html.erb +22 -0
  16. data/app/views/rails_markup/dashboard/_styles.html.erb +115 -0
  17. data/app/views/rails_markup/dashboard/board.html.erb +135 -0
  18. data/app/views/rails_markup/dashboard/index.html.erb +38 -0
  19. data/app/views/rails_markup/dashboard/show.html.erb +129 -0
  20. data/app/views/rails_markup/shared/_toolbar.html.erb +28 -0
  21. data/bin/rails-markup +5 -0
  22. data/config/routes.rb +45 -0
  23. data/db/migrate/20260720000000_add_client_uuid_to_rails_markup_annotations.rb +13 -0
  24. data/db/migrate/20260721000000_backfill_rails_markup_client_uuids.rb +17 -0
  25. data/lib/generators/rails_markup/install/templates/auth_controller.rb.erb +17 -0
  26. data/lib/generators/rails_markup/install/templates/bin_markup.erb +5 -0
  27. data/lib/generators/rails_markup/install/templates/create_rails_markup_annotations.rb.erb +27 -0
  28. data/lib/generators/rails_markup/install/templates/initializer.rb.erb +54 -0
  29. data/lib/generators/rails_markup/install_generator.rb +167 -0
  30. data/lib/generators/rails_markup/uninstall_generator.rb +110 -0
  31. data/lib/rails_markup/cli/base.rb +8 -0
  32. data/lib/rails_markup/cli/initializer_writer.rb +54 -0
  33. data/lib/rails_markup/cli/setup_wizard.rb +229 -0
  34. data/lib/rails_markup/cli.rb +831 -0
  35. data/lib/rails_markup/client_uuid_maintenance.rb +174 -0
  36. data/lib/rails_markup/configuration.rb +160 -0
  37. data/lib/rails_markup/engine.rb +18 -0
  38. data/lib/rails_markup/http_server.rb +226 -0
  39. data/lib/rails_markup/http_store_proxy.rb +122 -0
  40. data/lib/rails_markup/mcp_config.rb +258 -0
  41. data/lib/rails_markup/mcp_server.rb +639 -0
  42. data/lib/rails_markup/server.rb +73 -0
  43. data/lib/rails_markup/store.rb +219 -0
  44. data/lib/rails_markup/version.rb +5 -0
  45. data/lib/rails_markup.rb +11 -0
  46. data/lib/tasks/rails_markup_client_uuids.rake +19 -0
  47. metadata +198 -0
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module RailsMarkup
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("install/templates", __dir__)
12
+
13
+ desc "Install Rails Markup: create migration, initializer, auth controller, toolbar, and mount engine routes."
14
+
15
+ class_option :mount_path, type: :string, default: "/admin/annotations",
16
+ desc: "Path to mount the Rails Markup engine"
17
+ class_option :base_controller, type: :string, default: "ApplicationController",
18
+ desc: "Base controller class for authentication"
19
+ class_option :layout, type: :string, default: "application",
20
+ desc: "Layout to inject the toolbar into"
21
+ class_option :table_name, type: :string, default: "rails_markup_annotations",
22
+ desc: "Database table name for annotations (must match config.table_name)"
23
+
24
+ def copy_migration
25
+ migration_template "create_rails_markup_annotations.rb.erb",
26
+ "db/migrate/create_rails_markup_annotations.rb"
27
+ end
28
+
29
+ def create_initializer
30
+ template "initializer.rb.erb", "config/initializers/rails_markup.rb"
31
+ end
32
+
33
+ # Keep the model's config.table_name in lockstep with the table the
34
+ # migration actually creates when a custom name is requested.
35
+ def configure_table_name
36
+ return if options[:table_name] == "rails_markup_annotations"
37
+
38
+ gsub_file "config/initializers/rails_markup.rb",
39
+ /^\s*#?\s*config\.table_name\s*=.*$/,
40
+ %( config.table_name = "#{options[:table_name]}")
41
+ end
42
+
43
+ def create_auth_controller
44
+ template "auth_controller.rb.erb", "app/controllers/rails_markup_auth_controller.rb"
45
+ end
46
+
47
+ def mount_engine
48
+ mount_path = options[:mount_path]
49
+ route_line = %{mount RailsMarkup::Engine, at: "#{mount_path}" if defined?(RailsMarkup::Engine)}
50
+ route route_line
51
+ end
52
+
53
+ def inject_toolbar_into_layout
54
+ layout_path = "app/views/layouts/#{options[:layout]}.html.erb"
55
+
56
+ unless File.exist?(File.join(destination_root, layout_path))
57
+ say_status :skip, "#{layout_path} not found — add the toolbar manually", :yellow
58
+ return
59
+ end
60
+
61
+ toolbar_block = <<~ERB.indent(4)
62
+ <%# Rails Markup annotation toolbar %>
63
+ <% if lookup_context.exists?("rails_markup/shared/toolbar", [], true) %>
64
+ <%= render "rails_markup/shared/toolbar" %>
65
+ <% end %>
66
+ ERB
67
+
68
+ if File.read(File.join(destination_root, layout_path)).include?("rails_markup/shared/toolbar")
69
+ say_status :skip, "toolbar already present in #{layout_path}", :yellow
70
+ return
71
+ end
72
+
73
+ inject_into_file layout_path, toolbar_block, before: %r{</body>}
74
+ end
75
+
76
+ def inject_procfile
77
+ procfile = File.join(destination_root, "Procfile.dev")
78
+ return unless File.exist?(procfile)
79
+
80
+ content = File.read(procfile)
81
+
82
+ if content =~ /^markup:/
83
+ unless content =~ /^markup:\s*bin\/markup\s+server/
84
+ gsub_file "Procfile.dev", /^markup:.*$/, "markup: bin/markup server"
85
+ say_status :update, "Procfile.dev (markup → bin/markup server)", :green
86
+ else
87
+ say_status :skip, "Procfile.dev already uses bin/markup server", :yellow
88
+ end
89
+ else
90
+ append_to_file "Procfile.dev", "markup: bin/markup server\n"
91
+ say_status :append, "Procfile.dev (added markup server)", :green
92
+ end
93
+ end
94
+
95
+ def create_bin_wrapper
96
+ template "bin_markup.erb", "bin/markup"
97
+ chmod "bin/markup", 0o755
98
+ end
99
+
100
+ def configure_mcp
101
+ require_relative "../../rails_markup/mcp_config"
102
+ config = RailsMarkup::McpConfig.new(dir: destination_root)
103
+ dev_url = detect_dev_url
104
+ env_updates = {
105
+ "RAILS_MARKUP_DEV_URL" => dev_url,
106
+ "RAILS_MARKUP_MOUNT_PATH" => options[:mount_path]
107
+ }
108
+ config.update_env(env_updates)
109
+ say_status :create, ".mcp.json (dev URL: #{dev_url})", :green
110
+ end
111
+
112
+ def show_post_install
113
+ say ""
114
+ say "Rails Markup installed successfully!", :green
115
+ say ""
116
+ say "Created:"
117
+ say " - db/migrate/*_create_rails_markup_annotations.rb"
118
+ say " - config/initializers/rails_markup.rb"
119
+ say " - app/controllers/rails_markup_auth_controller.rb"
120
+ say " - bin/markup"
121
+ say " - .mcp.json (MCP config for AI agents)"
122
+ say ""
123
+ say "Next steps:"
124
+ say " 1. Run migrations: rails db:migrate"
125
+ say " 2. Visit dashboard: #{options[:mount_path]}"
126
+ say " 3. Configure auth in config/initializers/rails_markup.rb"
127
+ say ""
128
+ dev_url = detect_dev_url
129
+ unless dev_url == "http://localhost:3000"
130
+ say "Detected dev server: #{dev_url} (from Procfile.dev)"
131
+ end
132
+ say ""
133
+ say "Not using localhost:3000? Update your dev URL:", :yellow
134
+ say " bin/markup configure --dev-url=http://YOUR_HOST:PORT"
135
+ say ""
136
+ say "CLI commands:"
137
+ say " bin/markup fetch # See pending annotations"
138
+ say " bin/markup fetch --env=production # From production"
139
+ say " bin/markup configure --prod-url=URL --prod-token=TOKEN"
140
+ say " bin/markup status # Show current config"
141
+ say ""
142
+ end
143
+
144
+ private
145
+
146
+ def migration_version
147
+ "[#{ActiveRecord::Migration.current_version}]"
148
+ end
149
+
150
+ def detect_dev_url
151
+ # Try to read bind address and port from Procfile.dev
152
+ procfile = File.join(destination_root, "Procfile.dev")
153
+ if File.exist?(procfile)
154
+ content = File.read(procfile)
155
+ if content =~ /-b\s+([\d.]+)\s+-p\s+(\d+)/
156
+ return "http://#{$1}:#{$2}"
157
+ end
158
+ if content =~ /-p\s+(\d+)/
159
+ return "http://localhost:#{$1}"
160
+ end
161
+ end
162
+
163
+ "http://localhost:3000"
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module RailsMarkup
6
+ module Generators
7
+ class UninstallGenerator < Rails::Generators::Base
8
+ desc "Uninstall Rails Markup: remove initializer, auth controller, toolbar, routes, and bin wrapper."
9
+
10
+ class_option :remove_migration, type: :boolean, default: false,
11
+ desc: "Also remove the migration file (dangerous — only if table was never created)"
12
+
13
+ def remove_route_mount
14
+ route_file = File.join(destination_root, "config/routes.rb")
15
+ return unless File.exist?(route_file)
16
+
17
+ gsub_file "config/routes.rb", /^\s*mount RailsMarkup::Engine.*\n/, ""
18
+ say_status :remove, "RailsMarkup engine mount from routes", :green
19
+ end
20
+
21
+ def remove_initializer
22
+ path = "config/initializers/rails_markup.rb"
23
+ remove_file path if File.exist?(File.join(destination_root, path))
24
+ end
25
+
26
+ def remove_auth_controller
27
+ path = "app/controllers/rails_markup_auth_controller.rb"
28
+ full_path = File.join(destination_root, path)
29
+ return unless File.exist?(full_path)
30
+
31
+ content = File.read(full_path)
32
+ if content.include?("RailsMarkupAuthController")
33
+ remove_file path
34
+ else
35
+ say_status :skip, "#{path} doesn't match generated pattern — leaving in place", :yellow
36
+ end
37
+ end
38
+
39
+ def remove_toolbar_from_layouts
40
+ Dir.glob(File.join(destination_root, "app/views/layouts/*.html.erb")).each do |layout|
41
+ relative = layout.sub("#{destination_root}/", "")
42
+ content = File.read(layout)
43
+ next unless content.include?("rails_markup/shared/toolbar")
44
+
45
+ gsub_file relative, /^\s*<%# Rails Markup annotation toolbar %>\n/, ""
46
+ gsub_file relative, /^\s*<% if lookup_context\.exists\?\("rails_markup\/shared\/toolbar".*%>\n\s*<%= render "rails_markup\/shared\/toolbar" %>\n\s*<% end %>\n/, ""
47
+ say_status :remove, "toolbar from #{relative}", :green
48
+ end
49
+ end
50
+
51
+ def remove_mcp_config
52
+ mcp_path = File.join(destination_root, ".mcp.json")
53
+ return unless File.exist?(mcp_path)
54
+
55
+ config = JSON.parse(File.read(mcp_path))
56
+ server = config.dig("mcpServers", "rails-markup")
57
+ return unless server
58
+
59
+ config["mcpServers"].delete("rails-markup")
60
+
61
+ if config["mcpServers"].empty?
62
+ remove_file ".mcp.json"
63
+ else
64
+ File.write(mcp_path, JSON.pretty_generate(config) + "\n")
65
+ say_status :remove, "rails-markup entry from .mcp.json", :green
66
+ end
67
+ rescue JSON::ParserError
68
+ say_status :skip, ".mcp.json has invalid JSON — leaving in place", :yellow
69
+ end
70
+
71
+ def remove_bin_wrapper
72
+ path = "bin/markup"
73
+ full_path = File.join(destination_root, path)
74
+ return unless File.exist?(full_path)
75
+
76
+ content = File.read(full_path)
77
+ if content.include?("rails_markup") || content.include?("RailsMarkup")
78
+ remove_file path
79
+ else
80
+ say_status :skip, "#{path} doesn't reference rails_markup — leaving in place", :yellow
81
+ end
82
+ end
83
+
84
+ def remove_migration
85
+ return unless options[:remove_migration]
86
+
87
+ migration = Dir.glob(File.join(destination_root, "db/migrate/*_create_rails_markup_annotations.rb")).first
88
+ if migration
89
+ remove_file migration.sub("#{destination_root}/", "")
90
+ else
91
+ say_status :skip, "no rails_markup migration found", :yellow
92
+ end
93
+ end
94
+
95
+ def show_post_uninstall
96
+ say ""
97
+ say "Rails Markup uninstalled.", :green
98
+ say ""
99
+ say "Remaining manual steps:"
100
+ say " 1. Remove 'rails_markup' from your Gemfile and run bundle"
101
+ unless options[:remove_migration]
102
+ say " 2. Drop the table: rails db:migrate:down VERSION=<migration_version>"
103
+ say " Or: rails generate rails_markup:uninstall --remove-migration"
104
+ end
105
+ say " 3. Search for stale references: grep -r 'rails_markup\\|RailsMarkup' app/ config/"
106
+ say ""
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module RailsMarkup
6
+ class Cli < Thor
7
+ end
8
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module RailsMarkup
6
+ class Cli
7
+ # Reads/writes config/initializers/rails_markup.rb with upsert semantics.
8
+ # Handles toolbar_accent, toolbar_position, toolbar_size, enable_screenshots,
9
+ # toolbar_enabled, fab_visible.
10
+ class InitializerWriter
11
+ MANAGED_KEYS = %w[toolbar_accent toolbar_position toolbar_size enable_screenshots toolbar_enabled fab_visible].freeze
12
+
13
+ def initialize(dir: Dir.pwd)
14
+ @path = File.join(dir, "config", "initializers", "rails_markup.rb")
15
+ end
16
+
17
+ def write(**options)
18
+ content = File.exist?(@path) ? File.read(@path) : template
19
+ options.each do |key, value|
20
+ content = upsert_config(content, key.to_s, value)
21
+ end
22
+ File.write(@path, content)
23
+ end
24
+
25
+ private
26
+
27
+ def upsert_config(content, key, value)
28
+ formatted = format_value(value)
29
+ pattern = /^(\s*)#?\s*config\.#{Regexp.escape(key)}\s*=.*$/
30
+
31
+ if content.match?(pattern)
32
+ content.gsub(pattern, "\\1config.#{key} = #{formatted}")
33
+ else
34
+ content.sub(/^(end\s*)$/, " config.#{key} = #{formatted}\n\\1")
35
+ end
36
+ end
37
+
38
+ def format_value(value)
39
+ case value
40
+ when true, false then value.to_s
41
+ when Integer, Float then value.to_s
42
+ else %("#{value}")
43
+ end
44
+ end
45
+
46
+ def template
47
+ <<~RUBY
48
+ RailsMarkup.configure do |config|
49
+ end
50
+ RUBY
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bubbletea"
4
+ require "lipgloss"
5
+ require_relative "base"
6
+ require_relative "../mcp_config"
7
+ require_relative "initializer_writer"
8
+
9
+ module RailsMarkup
10
+ class Cli
11
+ # Interactive TUI setup wizard using Bubbletea's Elm architecture.
12
+ # State machine: accent → position → size → fab → screenshots → url → scope → confirm
13
+ class SetupWizard
14
+ include Bubbletea::Model
15
+
16
+ STEPS = %i[accent position size fab screenshots url scope confirm].freeze
17
+
18
+ STEP_CONFIG = {
19
+ accent: {
20
+ title: "Toolbar Accent",
21
+ type: :select,
22
+ options: %w[indigo amber blue emerald rose]
23
+ },
24
+ position: {
25
+ title: "Toolbar Position",
26
+ type: :select,
27
+ options: %w[bl br tl tr],
28
+ labels: { "bl" => "Bottom-left", "br" => "Bottom-right", "tl" => "Top-left", "tr" => "Top-right" }
29
+ },
30
+ size: {
31
+ title: "Toolbar Size",
32
+ type: :select,
33
+ options: %w[slim compact default],
34
+ labels: { "slim" => "Slim (32px)", "compact" => "Compact (40px)", "default" => "Default (48px)" }
35
+ },
36
+ fab: {
37
+ title: "Show Floating Button",
38
+ type: :select,
39
+ options: %w[yes no],
40
+ labels: { "yes" => "Yes — show the FAB", "no" => "No — hide the FAB (pins still show)" }
41
+ },
42
+ screenshots: {
43
+ title: "Enable Screenshots",
44
+ type: :select,
45
+ options: %w[yes no],
46
+ labels: { "yes" => "Yes", "no" => "No" }
47
+ },
48
+ url: {
49
+ title: "Production URL",
50
+ type: :text,
51
+ hint: "Optional — press Enter to skip"
52
+ },
53
+ scope: {
54
+ title: "MCP Server Scope",
55
+ type: :select,
56
+ options: %w[local global codex],
57
+ labels: {
58
+ "local" => "This project only (.mcp.json)",
59
+ "global" => "All projects — Claude Code (~/.claude/settings.json)",
60
+ "codex" => "All projects — Codex CLI (~/.codex/config.toml)"
61
+ }
62
+ },
63
+ confirm: {
64
+ title: "Confirm Setup",
65
+ type: :confirm
66
+ }
67
+ }.freeze
68
+
69
+ HEADER_STYLE = Lipgloss::Style.new.bold(true).foreground("#FFFFFF").background("#5C4AE4").padding(0, 1)
70
+ HINT_STYLE = Lipgloss::Style.new.foreground("#6B7280")
71
+ CURSOR_STYLE = Lipgloss::Style.new.bold(true).foreground("#818cf8")
72
+ OPTION_STYLE = Lipgloss::Style.new.foreground("#E2E2E2")
73
+
74
+ attr_reader :completed, :choices
75
+
76
+ def initialize(dir: Dir.pwd)
77
+ @dir = dir
78
+ @step_index = 0
79
+ @cursor = 0
80
+ @choices = {}
81
+ @text_input = ""
82
+ @completed = false
83
+ end
84
+
85
+ def init
86
+ [self, nil]
87
+ end
88
+
89
+ def update(message)
90
+ case message
91
+ when Bubbletea::KeyMessage
92
+ handle_key(message)
93
+ else
94
+ [self, nil]
95
+ end
96
+ end
97
+
98
+ def view
99
+ step = current_step
100
+ config = STEP_CONFIG[step]
101
+
102
+ lines = []
103
+ lines << HEADER_STYLE.render(" Rails Markup Setup — Step #{@step_index + 1}/#{STEPS.size} ")
104
+ lines << ""
105
+ lines << " #{config[:title]}"
106
+ lines << ""
107
+
108
+ case config[:type]
109
+ when :select
110
+ config[:options].each_with_index do |opt, i|
111
+ label = config.dig(:labels, opt) || opt
112
+ if i == @cursor
113
+ lines << " #{CURSOR_STYLE.render("▸")} #{label}"
114
+ else
115
+ lines << " #{OPTION_STYLE.render(label)}"
116
+ end
117
+ end
118
+ when :text
119
+ lines << " #{HINT_STYLE.render(config[:hint])}"
120
+ lines << ""
121
+ lines << " > #{@text_input}█"
122
+ when :confirm
123
+ lines << render_summary
124
+ lines << ""
125
+ lines << " #{HINT_STYLE.render("Press Enter to write config, Esc to cancel")}"
126
+ end
127
+
128
+ lines << ""
129
+ lines << " #{HINT_STYLE.render("↑/↓ navigate · Enter select · Esc cancel")}" unless step == :confirm
130
+
131
+ lines.join("\n")
132
+ end
133
+
134
+ private
135
+
136
+ def current_step
137
+ STEPS[@step_index]
138
+ end
139
+
140
+ def handle_key(msg)
141
+ return [self, Bubbletea.quit] if msg.esc?
142
+
143
+ step = current_step
144
+ config = STEP_CONFIG[step]
145
+
146
+ case config[:type]
147
+ when :select then handle_select(msg, config)
148
+ when :text then handle_text(msg)
149
+ when :confirm then handle_confirm(msg)
150
+ else [self, nil]
151
+ end
152
+ end
153
+
154
+ def handle_select(msg, config)
155
+ if msg.up?
156
+ @cursor = (@cursor - 1) % config[:options].size
157
+ elsif msg.down?
158
+ @cursor = (@cursor + 1) % config[:options].size
159
+ elsif msg.enter?
160
+ value = config[:options][@cursor]
161
+ store_choice(current_step, value)
162
+ advance_step
163
+ end
164
+
165
+ [self, nil]
166
+ end
167
+
168
+ def handle_text(msg)
169
+ if msg.enter?
170
+ store_choice(:url, @text_input.empty? ? nil : @text_input)
171
+ advance_step
172
+ elsif msg.backspace?
173
+ @text_input = @text_input[0..-2] || ""
174
+ elsif msg.runes?
175
+ @text_input += msg.char if msg.char
176
+ end
177
+
178
+ [self, nil]
179
+ end
180
+
181
+ def handle_confirm(msg)
182
+ if msg.enter?
183
+ write_config
184
+ @completed = true
185
+ return [self, Bubbletea.quit]
186
+ end
187
+
188
+ [self, nil]
189
+ end
190
+
191
+ def store_choice(step, value)
192
+ case step
193
+ when :accent then @choices[:toolbar_accent] = value
194
+ when :position then @choices[:toolbar_position] = value
195
+ when :size then @choices[:toolbar_size] = value
196
+ when :fab then @choices[:fab_visible] = (value == "yes")
197
+ when :screenshots then @choices[:enable_screenshots] = (value == "yes")
198
+ when :url then @choices[:url] = value
199
+ when :scope then @choices[:scope] = value
200
+ end
201
+ end
202
+
203
+ def advance_step
204
+ @step_index += 1
205
+ @cursor = 0
206
+ @text_input = ""
207
+ end
208
+
209
+ def write_config
210
+ writer = InitializerWriter.new(dir: @dir)
211
+ init_opts = @choices.except(:url, :scope)
212
+ writer.write(**init_opts)
213
+
214
+ scope = @choices[:scope] || "local"
215
+ config = McpConfig.new(dir: @dir, scope: scope)
216
+ env_vars = {}
217
+ env_vars["RAILS_MARKUP_PROD_URL"] = @choices[:url] if @choices[:url]
218
+ config.update_env(env_vars)
219
+ end
220
+
221
+ def render_summary
222
+ lines = @choices.map do |key, value|
223
+ " #{key}: #{value.nil? ? '(skipped)' : value}"
224
+ end
225
+ lines.join("\n")
226
+ end
227
+ end
228
+ end
229
+ end