cybertrain 0.1.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.
@@ -0,0 +1,227 @@
1
+ # `cybertrain generate scaffold post title:string body:text`: writes the
2
+ # migration, model, controller and views of a resource and adds
3
+ # `resources :posts` to config/routes.rb, the way Rails' scaffold does.
4
+ require "cybertrain/generator/inflector"
5
+ require "cybertrain/cli/templates"
6
+
7
+ module Cybertrain
8
+ module CLI
9
+ class InvalidArgument < StandardError
10
+ end
11
+
12
+ # One "name:type" argument. A references field ("post:references")
13
+ # becomes the post_id column.
14
+ class Field
15
+ TYPES = %w[string text integer float boolean date datetime references]
16
+
17
+ # Columns every table already has (the primary key and t.timestamps).
18
+ RESERVED_COLUMNS = %w[id created_at updated_at]
19
+
20
+ # Ruby keywords: a column (or resource) named after one would generate
21
+ # `def class` / `|end|` and break the generated code.
22
+ RUBY_KEYWORDS = %w[
23
+ __ENCODING__ __LINE__ __FILE__ BEGIN END alias and begin break case
24
+ class def defined do else elsif end ensure false for if in module
25
+ next nil not or redo rescue retry return self super then true undef
26
+ unless until when while yield
27
+ ]
28
+
29
+ attr_reader :field_name, :field_type
30
+
31
+ def self.parse(arg)
32
+ colons = 0
33
+ arg.each_char { |ch| colons += 1 if ch == ":" }
34
+ raise InvalidArgument, "bad field '#{arg}': use name:type (modifiers such as :index are not supported)" if colons > 1
35
+
36
+ parts = arg.split(":")
37
+ name = parts[0].to_s
38
+ type = parts.size > 1 ? parts[1].to_s : "string"
39
+ type = "references" if type == "belongs_to"
40
+ raise InvalidArgument, "bad field name '#{name}'" unless Templates.identifier?(name)
41
+ raise InvalidArgument, "'#{name}' is a Ruby keyword and cannot name a field" if RUBY_KEYWORDS.include?(name)
42
+ raise InvalidArgument, "unknown type '#{type}' for #{name} (use #{TYPES.join(", ")})" unless TYPES.include?(type)
43
+
44
+ field = Field.new(name, type)
45
+ raise InvalidArgument, "'#{field.column_name}' is a column every table already has" if RESERVED_COLUMNS.include?(field.column_name)
46
+
47
+ field
48
+ end
49
+
50
+ def initialize(field_name, field_type)
51
+ @field_name = field_name
52
+ @field_type = field_type
53
+ end
54
+
55
+ def reference?
56
+ @field_type == "references"
57
+ end
58
+
59
+ def column_name
60
+ reference? ? "#{@field_name}_id" : @field_name
61
+ end
62
+
63
+ def label
64
+ Templates.humanize(@field_name)
65
+ end
66
+
67
+ def migration_line
68
+ reference? ? "t.references :#{@field_name}" : "t.#{@field_type} \"#{@field_name}\""
69
+ end
70
+
71
+ # The FormBuilder method that edits this column.
72
+ def form_input
73
+ case @field_type
74
+ when "text" then "text_area"
75
+ when "integer", "float" then "number_field"
76
+ when "boolean" then "check_box"
77
+ else "text_field"
78
+ end
79
+ end
80
+ end
81
+
82
+ # The names a scaffold derives from its NAME argument ("post", "Post"
83
+ # or "posts" all give post / posts / Post / Posts).
84
+ class Resource
85
+ attr_reader :singular, :plural, :class_name, :plural_class, :fields
86
+
87
+ def initialize(name, fields)
88
+ @singular = Inflector.singularize(Inflector.underscore(name))
89
+ @plural = Inflector.pluralize(@singular)
90
+ @class_name = Inflector.camelize(@singular)
91
+ @plural_class = Inflector.camelize(@plural)
92
+ @fields = fields
93
+ end
94
+
95
+ # The column the model validates for presence ("" when there is none).
96
+ def first_string_field
97
+ @fields.each { |f| return f.field_name if f.field_type == "string" }
98
+ ""
99
+ end
100
+ end
101
+
102
+ module Scaffold
103
+ DRAW_LINE = "Cybertrain::Routes.draw do"
104
+
105
+ # Returns the paths it created (files that already exist are reported
106
+ # as "identical" or "exist" and left alone). Raises InvalidArgument on a
107
+ # bad name, a bad or duplicate field, or a routes file without a
108
+ # `Cybertrain::Routes.draw do` line, before anything is written.
109
+ def self.generate(root, name, fields)
110
+ routes_path = File.join(root, "config/routes.rb")
111
+ raise InvalidArgument, "#{routes_path} not found: run this inside a cybertrain app" unless File.exist?(routes_path)
112
+ raise InvalidArgument, "#{routes_path} has no `#{DRAW_LINE}` line" if File.read(routes_path).index(DRAW_LINE).nil?
113
+
114
+ underscored = Inflector.underscore(name)
115
+ raise InvalidArgument, "bad resource name '#{name}'" unless Templates.identifier?(underscored)
116
+ raise InvalidArgument, "'#{name}' is a Ruby keyword and cannot name a resource" if Field::RUBY_KEYWORDS.include?(Inflector.singularize(underscored))
117
+
118
+ parsed = Array.new(0) { Field.new("", "") }
119
+ columns = Array.new(0) { "" }
120
+ fields.each do |arg|
121
+ field = Field.parse(arg)
122
+ raise InvalidArgument, "duplicate column '#{field.column_name}'" if columns.include?(field.column_name)
123
+
124
+ columns << field.column_name
125
+ parsed << field
126
+ end
127
+ res = Resource.new(name, parsed)
128
+
129
+ created = Array.new(0) { "" }
130
+ migration = migration_path(root, res)
131
+ record(created, root, migration, Templates.migration(res))
132
+ record(created, root, "app/models/#{res.singular}.rb", Templates.model(res))
133
+ record(created, root, "app/controllers/#{res.plural}_controller.rb", Templates.controller(res))
134
+ views = "app/views/#{res.plural}"
135
+ record(created, root, "#{views}/index.html.erb", Templates.index_view(res))
136
+ record(created, root, "#{views}/show.html.erb", Templates.show_view(res))
137
+ record(created, root, "#{views}/new.html.erb", Templates.new_view(res))
138
+ record(created, root, "#{views}/edit.html.erb", Templates.edit_view(res))
139
+ record(created, root, "#{views}/_form.html.erb", Templates.form_partial(res))
140
+ add_route(routes_path, res.plural)
141
+ created
142
+ end
143
+
144
+ def self.record(created, root, path, content)
145
+ created << path if Templates.write(root, path, content) == "create"
146
+ nil
147
+ end
148
+
149
+ # db/migrate/<timestamp>_create_posts.rb, or the existing
150
+ # *_create_posts.rb when the scaffold already ran.
151
+ def self.migration_path(root, res)
152
+ suffix = "_create_#{res.plural}.rb"
153
+ existing = Dir.glob(File.join(root, "db/migrate/*#{suffix}")).sort
154
+ return "db/migrate/#{File.basename(existing[0])}" unless existing.empty?
155
+
156
+ "db/migrate/#{next_version(root, timestamp)}#{suffix}"
157
+ end
158
+
159
+ # The version a new migration gets: stamp, unless db/migrate already
160
+ # holds a version >= stamp (two scaffolds in the same second, or a
161
+ # fixed CYBERTRAIN_TIMESTAMP), in which case the largest one + 1, the
162
+ # way Rails does it. Versions must stay unique (schema_migrations keys
163
+ # on them) and increasing (they order the migrations).
164
+ def self.next_version(root, stamp)
165
+ largest = 0
166
+ Dir.glob(File.join(root, "db/migrate/*.rb")).each do |path|
167
+ digits = leading_digits(File.basename(path))
168
+ next if digits == ""
169
+
170
+ v = digits.to_i
171
+ largest = v if v > largest
172
+ end
173
+ return stamp if stamp.to_i > largest
174
+
175
+ (largest + 1).to_s
176
+ end
177
+
178
+ def self.leading_digits(name)
179
+ out = +""
180
+ name.each_char do |ch|
181
+ break unless ch >= "0" && ch <= "9"
182
+
183
+ out << ch
184
+ end
185
+ out
186
+ end
187
+
188
+ def self.timestamp
189
+ fixed = ENV["CYBERTRAIN_TIMESTAMP"]
190
+ return fixed.to_s unless fixed.nil? || fixed == ""
191
+
192
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
193
+ end
194
+
195
+ # Inserts ` resources :posts` right after `Cybertrain::Routes.draw do`
196
+ # unless a `resources :posts` line is already there.
197
+ def self.add_route(routes_path, plural)
198
+ route = "resources :#{plural}"
199
+ source = File.read(routes_path)
200
+ lines = source.split("\n")
201
+ present = false
202
+ lines.each do |line|
203
+ l = line.strip
204
+ present = true if l == route || l.start_with?("#{route} ") || l.start_with?("#{route},")
205
+ end
206
+ if present
207
+ puts "identical route #{route}"
208
+ return false
209
+ end
210
+
211
+ at = source.index(DRAW_LINE)
212
+ raise InvalidArgument, "#{routes_path} has no `#{DRAW_LINE}` line" if at.nil?
213
+
214
+ eol = source.index("\n", at)
215
+ eol = source.size if eol.nil?
216
+ updated = +""
217
+ updated << source[0, eol]
218
+ updated << "\n #{route}"
219
+ updated << source[eol, source.size - eol]
220
+ updated << "\n" unless updated.end_with?("\n")
221
+ File.write(routes_path, updated)
222
+ puts "route #{route}"
223
+ true
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,421 @@
1
+ # Cybertrain::CLI::Templates -- the file contents `cybertrain new` and
2
+ # `cybertrain generate scaffold` write, embedded in the binary, plus the one
3
+ # helper that puts them on disk.
4
+ #
5
+ # The scaffold templates take a Cybertrain::CLI::Resource (cli/scaffold.rb).
6
+ # Generated views stay inside the template language of docs/design.md
7
+ # section 7: literals, @ivars, locals, helper calls, `if` and `each do`.
8
+ module Cybertrain
9
+ module CLI
10
+ module Templates
11
+ # Writes root/path unless it already exists, creating directories on
12
+ # the way. Returns "create", "identical" (same content already there)
13
+ # or "exist" (different content: left alone, never overwritten), and
14
+ # prints that status with the path.
15
+ def self.write(root, path, content)
16
+ full = File.join(root, path)
17
+ status = "create"
18
+ if File.exist?(full)
19
+ status = File.read(full) == content ? "identical" : "exist"
20
+ else
21
+ mkdir_p(File.dirname(full))
22
+ File.write(full, content)
23
+ end
24
+ puts "#{status} #{path}"
25
+ status
26
+ end
27
+
28
+ def self.mkdir_p(dir)
29
+ return if dir == "" || File.directory?(dir)
30
+
31
+ path = dir.start_with?("/") ? +"/" : +""
32
+ dir.split("/").each do |part|
33
+ next if part == ""
34
+
35
+ path << part
36
+ Dir.mkdir(path) unless File.directory?(path)
37
+ path << "/"
38
+ end
39
+ nil
40
+ end
41
+
42
+ # A lowercase Ruby-ish name: "post", "blog_post", "released_on".
43
+ def self.identifier?(word)
44
+ return false if word == ""
45
+
46
+ ok = true
47
+ first = true
48
+ word.each_char do |ch|
49
+ lower = ch >= "a" && ch <= "z"
50
+ digit = ch >= "0" && ch <= "9"
51
+ ok = false unless lower || (!first && (digit || ch == "_"))
52
+ first = false
53
+ end
54
+ ok
55
+ end
56
+
57
+ # "blog_posts" -> "Blog posts"
58
+ def self.humanize(word)
59
+ word.tr("_", " ").capitalize
60
+ end
61
+
62
+ # ---- cybertrain new ----------------------------------------------
63
+
64
+ # framework_dep is the TOML value: `{ path = "..." }` or `"~> 0.1"`.
65
+ def self.spin_toml(package, framework_dep)
66
+ <<~TOML
67
+ [package]
68
+ name = "#{package}"
69
+ version = "0.1.0"
70
+
71
+ [dependencies]
72
+ cybertrain = #{framework_dep}
73
+ TOML
74
+ end
75
+
76
+ def self.gitignore
77
+ <<~TEXT
78
+ /build/
79
+ /storage/*.sqlite3*
80
+ /tmp/*
81
+ !/tmp/.keep
82
+ /log/
83
+ TEXT
84
+ end
85
+
86
+ def self.readme(title)
87
+ <<~MARKDOWN
88
+ # #{title}
89
+
90
+ A cybertrain application, compiled to one binary by Spinel.
91
+
92
+ ```sh
93
+ cybertrain generate scaffold post title:string body:text
94
+ spin run gen # pick up the new migration (gen/migrations.rb)
95
+ spin run db -- migrate # apply db/migrate, rewrite db/schema.rb
96
+ spin run gen # regenerate gen/ from db/schema.rb, config/routes.rb and app/
97
+ spin run server # http://127.0.0.1:3000
98
+ ```
99
+
100
+ Run `spin run gen` after changing the schema, the routes or a
101
+ controller's instance variables and callbacks, and commit `gen/`.
102
+ Views under `app/views/` are read at run time: edit them without
103
+ rebuilding.
104
+ MARKDOWN
105
+ end
106
+
107
+ def self.config_app
108
+ <<~RUBY
109
+ # Application settings; see Cybertrain::Config for every option.
110
+ # Environment variables (PORT, CYBERTRAIN_ENV, CYBERTRAIN_DATABASE,
111
+ # CYBERTRAIN_SECRET_KEY_BASE) are read before this block runs.
112
+ Cybertrain.configure do |c|
113
+ # c.port = 3000
114
+ # c.workers = 1
115
+
116
+ # Production marks the session cookie Secure (HTTPS only). Turn it
117
+ # off only if production is really served over plain http://.
118
+ # c.session_secure = false
119
+ end
120
+ RUBY
121
+ end
122
+
123
+ def self.routes
124
+ "Cybertrain::Routes.draw do\nend\n"
125
+ end
126
+
127
+ def self.schema
128
+ "Cybertrain::Schema.define(version: \"0\") do |s|\nend\n"
129
+ end
130
+
131
+ def self.application_controller
132
+ <<~RUBY
133
+ class ApplicationController < Cybertrain::Controller
134
+ rescue_from Cybertrain::RecordNotFound, with: :record_not_found
135
+
136
+ private
137
+
138
+ def record_not_found
139
+ render plain: "Not Found", status: :not_found
140
+ end
141
+ end
142
+ RUBY
143
+ end
144
+
145
+ def self.layout(title)
146
+ <<~ERB
147
+ <!DOCTYPE html>
148
+ <html>
149
+ <head>
150
+ <title>#{title}</title>
151
+ <meta name="viewport" content="width=device-width,initial-scale=1">
152
+ <%= csrf_meta_tags %>
153
+ <link rel="stylesheet" href="/style.css">
154
+ </head>
155
+ <body>
156
+ <main>
157
+ <% if flash[:notice] %>
158
+ <p class="notice"><%= flash[:notice] %></p>
159
+ <% end %>
160
+ <% if flash[:alert] %>
161
+ <p class="alert"><%= flash[:alert] %></p>
162
+ <% end %>
163
+ <%= yield %>
164
+ </main>
165
+ </body>
166
+ </html>
167
+ ERB
168
+ end
169
+
170
+ def self.error_page(status, message)
171
+ <<~HTML
172
+ <!DOCTYPE html>
173
+ <html>
174
+ <head>
175
+ <title>#{message} (#{status})</title>
176
+ <meta name="viewport" content="width=device-width,initial-scale=1">
177
+ <link rel="stylesheet" href="/style.css">
178
+ </head>
179
+ <body>
180
+ <main>
181
+ <h1>#{message}</h1>
182
+ <p>#{status == "404" ? "The page you were looking for doesn't exist." : "Something went wrong on our side."}</p>
183
+ </main>
184
+ </body>
185
+ </html>
186
+ HTML
187
+ end
188
+
189
+ def self.style_css
190
+ <<~CSS
191
+ body { font-family: system-ui, sans-serif; line-height: 1.5; margin: 0; color: #222; }
192
+ main { max-width: 48rem; margin: 0 auto; padding: 1rem; }
193
+ a { color: #0b5cad; }
194
+ table { border-collapse: collapse; width: 100%; }
195
+ th, td { text-align: left; padding: 0.25rem 0.5rem; border-bottom: 1px solid #ddd; }
196
+ .notice { color: #166534; background: #dcfce7; padding: 0.5rem; }
197
+ .alert { color: #991b1b; background: #fee2e2; padding: 0.5rem; }
198
+ .field { margin-bottom: 0.75rem; }
199
+ .field label { display: block; font-weight: 600; }
200
+ .field input[type=text], .field input[type=number], .field textarea { width: 100%; padding: 0.25rem; }
201
+ .field_with_errors input, .field_with_errors textarea { border: 1px solid #b91c1c; }
202
+ #error_explanation { color: #991b1b; border: 1px solid #fca5a5; padding: 0.5rem 1rem; margin-bottom: 1rem; }
203
+ form.button_to { display: inline; }
204
+ CSS
205
+ end
206
+
207
+ def self.bin_server
208
+ <<~RUBY
209
+ require "cybertrain"
210
+ require_relative "../config/app"
211
+ require_relative "../gen/app"
212
+
213
+ app = Cybertrain::Application.new(
214
+ router: Gen::Routes.build(Cybertrain::Router.new),
215
+ url_resolver: Gen::Routes.url_resolver
216
+ )
217
+ app.run
218
+ RUBY
219
+ end
220
+
221
+ def self.bin_gen
222
+ <<~RUBY
223
+ require "cybertrain/generator"
224
+ require_relative "../config/routes"
225
+ require_relative "../db/schema"
226
+
227
+ exit(Cybertrain::Gen::Runner.run(".", ARGV))
228
+ RUBY
229
+ end
230
+
231
+ def self.bin_db
232
+ <<~RUBY
233
+ require "cybertrain"
234
+ require_relative "../config/app"
235
+ require_relative "../gen/migrations"
236
+
237
+ exit(Cybertrain::DB::CLI.run(ARGV))
238
+ RUBY
239
+ end
240
+
241
+ # ---- cybertrain generate scaffold --------------------------------
242
+
243
+ def self.migration(res)
244
+ buf = +""
245
+ buf << "class Create#{res.plural_class} < Cybertrain::Migration::Base\n"
246
+ buf << " def change\n"
247
+ buf << " create_table \"#{res.plural}\" do |t|\n"
248
+ res.fields.each { |f| buf << " #{f.migration_line}\n" }
249
+ buf << " t.timestamps\n"
250
+ buf << " end\n"
251
+ buf << " end\n"
252
+ buf << "end\n"
253
+ buf
254
+ end
255
+
256
+ def self.model(res)
257
+ buf = +""
258
+ buf << "# Columns, associations and the Cybertrain::Model superclass come from\n"
259
+ buf << "# gen/models/#{res.singular}.rb, generated from db/schema.rb by `spin run gen`.\n"
260
+ buf << "class #{res.class_name}\n"
261
+ first = res.first_string_field
262
+ buf << " validates :#{first}, presence: true\n" unless first == ""
263
+ buf << "end\n"
264
+ buf
265
+ end
266
+
267
+ def self.controller(res)
268
+ s = res.singular
269
+ p = res.plural
270
+ human = humanize(s)
271
+ permitted = res.fields.map { |f| ":#{f.column_name}" }.join(", ")
272
+ <<~RUBY
273
+ class #{res.plural_class}Controller < ApplicationController
274
+ before_action :set_#{s}, only: [:show, :edit, :update, :destroy]
275
+
276
+ # GET /#{p}
277
+ def index
278
+ @#{p} = #{res.class_name}.all.to_a
279
+ end
280
+
281
+ # GET /#{p}/1
282
+ def show
283
+ end
284
+
285
+ # GET /#{p}/new
286
+ # (named new_action: a `new` method would shadow #{res.plural_class}Controller.new)
287
+ def new_action
288
+ @#{s} = #{res.class_name}.new
289
+ end
290
+
291
+ # GET /#{p}/1/edit
292
+ def edit
293
+ end
294
+
295
+ # POST /#{p}
296
+ def create
297
+ @#{s} = #{res.class_name}.new(#{s}_params)
298
+ if @#{s}.save
299
+ flash[:notice] = "#{human} was successfully created."
300
+ redirect_to #{s}_path(@#{s}), status: :see_other
301
+ else
302
+ render :new, status: :unprocessable_entity
303
+ end
304
+ end
305
+
306
+ # PATCH/PUT /#{p}/1
307
+ def update
308
+ if @#{s}.update(#{s}_params)
309
+ flash[:notice] = "#{human} was successfully updated."
310
+ redirect_to #{s}_path(@#{s}), status: :see_other
311
+ else
312
+ render :edit, status: :unprocessable_entity
313
+ end
314
+ end
315
+
316
+ # DELETE /#{p}/1
317
+ def destroy
318
+ @#{s}.destroy
319
+ flash[:notice] = "#{human} was successfully destroyed."
320
+ redirect_to #{p}_path, status: :see_other
321
+ end
322
+
323
+ private
324
+
325
+ def set_#{s}
326
+ @#{s} = #{res.class_name}.find(params[:id])
327
+ end
328
+
329
+ def #{s}_params
330
+ params.require(:#{s}).permit(#{permitted})
331
+ end
332
+ end
333
+ RUBY
334
+ end
335
+
336
+ def self.index_view(res)
337
+ s = res.singular
338
+ words = s.tr("_", " ")
339
+ buf = +""
340
+ buf << "<h1>#{humanize(res.plural)}</h1>\n\n"
341
+ buf << "<table>\n <thead>\n <tr>\n"
342
+ res.fields.each { |f| buf << " <th>#{f.label}</th>\n" }
343
+ buf << " <th></th>\n </tr>\n </thead>\n <tbody>\n"
344
+ buf << " <% @#{res.plural}.each do |#{s}| %>\n"
345
+ buf << " <tr>\n"
346
+ res.fields.each { |f| buf << " <td><%= #{s}.#{f.column_name} %></td>\n" }
347
+ buf << " <td><%= link_to \"Show\", #{s}_path(#{s}) %></td>\n"
348
+ buf << " </tr>\n"
349
+ buf << " <% end %>\n"
350
+ buf << " </tbody>\n</table>\n\n"
351
+ buf << "<p><%= link_to \"New #{words}\", new_#{s}_path %></p>\n"
352
+ buf
353
+ end
354
+
355
+ def self.show_view(res)
356
+ s = res.singular
357
+ words = s.tr("_", " ")
358
+ buf = +""
359
+ buf << "<h1>#{humanize(s)}</h1>\n\n"
360
+ res.fields.each do |f|
361
+ buf << "<p>\n <strong>#{f.label}:</strong>\n <%= @#{s}.#{f.column_name} %>\n</p>\n\n"
362
+ end
363
+ buf << "<p>\n"
364
+ buf << " <%= link_to \"Edit this #{words}\", edit_#{s}_path(@#{s}) %> |\n"
365
+ buf << " <%= link_to \"Back to #{res.plural.tr("_", " ")}\", #{res.plural}_path %>\n"
366
+ buf << "</p>\n\n"
367
+ buf << "<%= button_to \"Destroy this #{words}\", #{s}_path(@#{s}), method: :delete %>\n"
368
+ buf
369
+ end
370
+
371
+ def self.new_view(res)
372
+ s = res.singular
373
+ buf = +""
374
+ buf << "<h1>New #{s.tr("_", " ")}</h1>\n\n"
375
+ buf << "<%= render \"form\", #{s}: @#{s} %>\n\n"
376
+ buf << "<p><%= link_to \"Back to #{res.plural.tr("_", " ")}\", #{res.plural}_path %></p>\n"
377
+ buf
378
+ end
379
+
380
+ def self.edit_view(res)
381
+ s = res.singular
382
+ buf = +""
383
+ buf << "<h1>Editing #{s.tr("_", " ")}</h1>\n\n"
384
+ buf << "<%= render \"form\", #{s}: @#{s} %>\n\n"
385
+ buf << "<p>\n"
386
+ buf << " <%= link_to \"Show this #{s.tr("_", " ")}\", #{s}_path(@#{s}) %> |\n"
387
+ buf << " <%= link_to \"Back to #{res.plural.tr("_", " ")}\", #{res.plural}_path %>\n"
388
+ buf << "</p>\n"
389
+ buf
390
+ end
391
+
392
+ def self.form_partial(res)
393
+ s = res.singular
394
+ buf = +""
395
+ buf << "<%# locals: (#{s}:) %>\n"
396
+ buf << "<%= form_with(model: #{s}) do |f| %>\n"
397
+ buf << " <% if #{s}.errors.any? %>\n"
398
+ buf << " <div id=\"error_explanation\">\n"
399
+ buf << " <h2><%= pluralize(#{s}.errors.count, \"error\") %> prohibited this #{s.tr("_", " ")} from being saved:</h2>\n"
400
+ buf << " <ul>\n"
401
+ buf << " <% #{s}.errors.full_messages.each do |message| %>\n"
402
+ buf << " <li><%= message %></li>\n"
403
+ buf << " <% end %>\n"
404
+ buf << " </ul>\n"
405
+ buf << " </div>\n"
406
+ buf << " <% end %>\n\n"
407
+ res.fields.each do |f|
408
+ buf << " <div class=\"field\">\n"
409
+ buf << " <%= f.label :#{f.column_name} %>\n"
410
+ buf << " <%= f.#{f.form_input} :#{f.column_name} %>\n"
411
+ buf << " </div>\n\n"
412
+ end
413
+ buf << " <div class=\"actions\">\n"
414
+ buf << " <%= f.submit %>\n"
415
+ buf << " </div>\n"
416
+ buf << "<% end %>\n"
417
+ buf
418
+ end
419
+ end
420
+ end
421
+ end