cybertrain 0.1.1 → 0.2.1

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.
@@ -1,10 +1,42 @@
1
1
  # `cybertrain build`: dist/ = the app binary with app/views embedded, plus
2
- # public/. Also the command lists `cybertrain migration` and `server` run. Plain Ruby:
2
+ # public/. Also the command lists `cybertrain db` and `server` run. Plain Ruby:
3
3
  # runs under CRuby (the gem) and compiles under Spinel (spin install).
4
+ require "cybertrain/app_name"
5
+
4
6
  module Cybertrain
5
7
  module CLI
6
8
  module Build
7
- # The [package] name in root/spin.toml, or "" when there is none.
9
+ # The entries assemble() keeps in dist/ besides the binary: the copied
10
+ # public/ and the storage/ and tmp/ directories. A package named like
11
+ # one of them would collide with it (the binary renamed to dist/public
12
+ # gets moved aside as dist/.public.old and deleted; a dist/storage
13
+ # directory cannot be replaced by the binary), so `cybertrain build`
14
+ # (require_dist_name!) and `cybertrain new` refuse these names;
15
+ # app_name, which every command reads, does not: `cybertrain db` and
16
+ # `server` work for an existing app named like one. assemble builds its
17
+ # paths from these constants so the two cannot drift.
18
+ DIST_PUBLIC = "public"
19
+ DIST_STORAGE = "storage"
20
+ DIST_TMP = "tmp"
21
+ DIST_ENTRIES = [DIST_PUBLIC, DIST_STORAGE, DIST_TMP]
22
+
23
+ # "" when name cannot collide with what assemble() puts in dist/, else
24
+ # the reason. A leading "." covers assemble's scratch entries
25
+ # (dist/.<name>.tmp, dist/.public.tmp, dist/.public.old) and "."/"..".
26
+ # The entry names match case-insensitively: on a case-insensitive
27
+ # filesystem (macOS's default APFS, one of the CI OSes; Windows) "Public"
28
+ # and "TMP" are the same directory entry as public and tmp. Only a
29
+ # different spelling of the whole name ("tmp2", "my_public") is another
30
+ # entry. downcase is ASCII-safe here: the entries are ASCII.
31
+ def self.dist_name_problem(name)
32
+ return "" unless DIST_ENTRIES.include?(name.downcase) || name.start_with?(".")
33
+
34
+ "collides with what `cybertrain build` keeps in dist/ (#{DIST_ENTRIES.join(", ")}) or its scratch entries: it cannot be one of those names (in any letter case) or start with '.'"
35
+ end
36
+
37
+ # The [package] name in root/spin.toml, or "" when there is none. The
38
+ # name ends up in shell command strings and file paths, so it must be
39
+ # a name `cybertrain new` could have produced.
8
40
  def self.app_name(root)
9
41
  path = "#{root}/spin.toml"
10
42
  return "" unless File.exist?(path)
@@ -22,22 +54,65 @@ module Cybertrain
22
54
  break
23
55
  end
24
56
  end
57
+ # An empty name is run_in_app's "no spin.toml with a [package] name".
58
+ # Any other name must be usable as a `spin build` target and a
59
+ # build/bin/<name> path: the same predicate Application.new applies to
60
+ # the dev rebuilder's target. The name reaches the shell through
61
+ # quote_arg, so only what breaks that target or path is refused.
62
+ return "" if name.empty?
63
+
64
+ problem = AppName.problem(name)
65
+ raise InvalidArgument, "spin.toml [package] name '#{name}' #{problem}" unless problem.empty?
66
+
25
67
  name
26
68
  end
27
69
 
70
+ # The build path's check, made once in CLI.build_app before the
71
+ # toolchain is touched (Build.run does not repeat it): a name that
72
+ # collides with a dist/ entry only matters to assemble. Returns nil.
73
+ def self.require_dist_name!(name)
74
+ problem = dist_name_problem(name)
75
+ raise InvalidArgument, "spin.toml [package] name '#{name}' #{problem}" unless problem.empty?
76
+
77
+ nil
78
+ end
79
+
80
+ # name goes through quote_arg too: app_name validates it, but the
81
+ # command strings stay safe for a caller that skipped that, whatever the
82
+ # spelling. An empty name cannot reach these from the CLI (run_in_app
83
+ # refuses it first); a direct caller passing "" gets `spin build ''`.
28
84
  def self.commands(name)
29
- ["spin run gen -- --embed-views", "spin build #{name}", "spin run gen"]
85
+ ["spin run gen -- --embed-views", "spin build #{quote_arg(name)}", "spin run gen"]
86
+ end
87
+
88
+ # `cybertrain db ARGS`, through bin/db.rb (the app binary cannot compile
89
+ # before the first migration has produced gen/models). bin/db.rb only
90
+ # knows the migrations gen/migrations.rb lists, so gen runs first;
91
+ # migrate and rollback rewrite db/schema.rb, which gen/models is
92
+ # derived from, so gen runs again after them.
93
+ def self.db_commands(args)
94
+ words = args.map { |arg| quote_arg(arg) }.join(" ")
95
+ commands = ["spin run gen", "spin run db -- #{words}"]
96
+ commands << "spin run gen" if args[0] == "migrate" || args[0] == "rollback"
97
+ commands
30
98
  end
31
99
 
32
- # Through bin/db.rb: the app binary cannot compile before the first
33
- # migration has produced gen/models.
100
+ # `cybertrain migration`: the same as `cybertrain db migrate`.
34
101
  def self.migration_commands
35
- ["spin run gen", "spin run db -- migrate", "spin run gen"]
102
+ db_commands(["migrate"])
103
+ end
104
+
105
+ # A word sh reads as itself stays bare; anything else is single-quoted.
106
+ def self.quote_arg(text)
107
+ return text if !text.empty? && text.match(/\A[A-Za-z0-9_:.\/-]+\z/)
108
+
109
+ shell_quote(text)
36
110
  end
37
111
 
38
112
  # port is "" (the app's default, 3000) or a port? string.
39
113
  def self.server_commands(name, port)
40
- run = port == "" ? "spin run #{name}" : "spin run #{name} -- #{port}"
114
+ target = quote_arg(name)
115
+ run = port == "" ? "spin run #{target}" : "spin run #{target} -- #{quote_arg(port)}"
41
116
  ["spin run gen", run]
42
117
  end
43
118
 
@@ -66,6 +141,8 @@ module Cybertrain
66
141
  end
67
142
 
68
143
  # Runs the build in root and assembles dist/. Returns the exit code.
144
+ # name must already have passed require_dist_name!: CLI.build_app checks
145
+ # it once, before the toolchain is fetched.
69
146
  def self.run(root, name, runner = Runner.new)
70
147
  steps = commands(name)
71
148
  ok = false
@@ -120,8 +197,8 @@ module Cybertrain
120
197
  dist = "#{root}/dist"
121
198
  Templates.mkdir_p(dist)
122
199
  binary_tmp = "#{dist}/.#{name}.tmp"
123
- public_tmp = "#{dist}/.public.tmp"
124
- public_old = "#{dist}/.public.old"
200
+ public_tmp = "#{dist}/.#{DIST_PUBLIC}.tmp"
201
+ public_old = "#{dist}/.#{DIST_PUBLIC}.old"
125
202
  # Leftovers of an interrupted earlier run.
126
203
  rm_tree(binary_tmp)
127
204
  rm_tree(public_tmp)
@@ -132,20 +209,20 @@ module Cybertrain
132
209
 
133
210
  File.rename(binary_tmp, "#{dist}/#{name}")
134
211
 
135
- if File.directory?("#{root}/public")
136
- public_command = "cp -R #{shell_quote("#{root}/public")} #{shell_quote(public_tmp)}"
137
- raise InvalidArgument, "could not copy public/ to dist/" unless system(public_command)
212
+ if File.directory?("#{root}/#{DIST_PUBLIC}")
213
+ public_command = "cp -R #{shell_quote("#{root}/#{DIST_PUBLIC}")} #{shell_quote(public_tmp)}"
214
+ raise InvalidArgument, "could not copy #{DIST_PUBLIC}/ to dist/" unless system(public_command)
138
215
  else
139
216
  Dir.mkdir(public_tmp)
140
217
  end
141
- public_dir = "#{dist}/public"
218
+ public_dir = "#{dist}/#{DIST_PUBLIC}"
142
219
  File.rename(public_dir, public_old) if File.exist?(public_dir) || File.symlink?(public_dir)
143
220
  File.rename(public_tmp, public_dir)
144
221
  rm_tree(public_old)
145
222
 
146
- Templates.mkdir_p("#{dist}/storage")
147
- Templates.mkdir_p("#{dist}/tmp")
148
- ["dist/#{name}", "dist/public/", "dist/storage/", "dist/tmp/"]
223
+ Templates.mkdir_p("#{dist}/#{DIST_STORAGE}")
224
+ Templates.mkdir_p("#{dist}/#{DIST_TMP}")
225
+ ["dist/#{name}", "dist/#{DIST_PUBLIC}/", "dist/#{DIST_STORAGE}/", "dist/#{DIST_TMP}/"]
149
226
  end
150
227
 
151
228
  # A symlink is removed itself, never followed: its target may be
@@ -1,12 +1,12 @@
1
1
  # `cybertrain new blog`: writes the skeleton of an application package.
2
2
  require "cybertrain/generator/inflector"
3
3
  require "cybertrain/cli/templates"
4
+ require "cybertrain/cli/build"
5
+ require "cybertrain/cli/toolchain"
4
6
 
5
7
  module Cybertrain
6
8
  module CLI
7
9
  module NewApp
8
- KEEP_DIRS = ["db/migrate", "app/models", "app/helpers", "gen", "storage", "tmp", "test"]
9
-
10
10
  # dir is the directory to create ("blog" or "path/to/blog"; its last
11
11
  # component names the package). framework_dep is the TOML value that
12
12
  # spin.toml's `cybertrain =` line gets: `{ path = "/abs/cybertrain" }` or
@@ -14,6 +14,11 @@ module Cybertrain
14
14
  # relative to dir.
15
15
  def self.create(dir, framework_dep)
16
16
  package = File.basename(dir)
17
+ # Before anything is written: `public`, `storage` and `tmp` are valid
18
+ # identifiers but collide with dist/ entries (Build.dist_name_problem).
19
+ problem = Build.dist_name_problem(package)
20
+ raise InvalidArgument, "'#{package}' is not a valid app name: it #{problem}" unless problem.empty?
21
+
17
22
  title = Inflector.camelize(package)
18
23
  files = [
19
24
  ["spin.toml", Templates.spin_toml(package, framework_dep)],
@@ -47,28 +52,33 @@ module Cybertrain
47
52
 
48
53
  # What `rails new` does with `bundle install`: resolve and lock the
49
54
  # framework (fetching it into spin's cache, ~/.cache/spin), then write
50
- # gen/ so `spin build` works straight away. Without spin on PATH it
51
- # only says what to run later. Returns false when a step failed.
55
+ # gen/ so `spin build` works straight away. Installs Spinel first when
56
+ # this machine has none of the pinned release (Toolchain.ensure!).
57
+ # Returns false when a step failed.
52
58
  def self.bootstrap(dir)
53
- unless system("command -v spin > /dev/null 2>&1")
54
- puts "skip spin lock / spin run gen: `spin` is not on PATH"
55
- puts " install Spinel (https://github.com/matz/spinel), then: #{bootstrap_command(dir)}"
56
- return true
59
+ unless Toolchain.ensure!
60
+ puts "skip spin lock / spin run gen: Spinel #{Cybertrain::SPINEL_TAG} is not available"
61
+ puts " fix the problem above (`cybertrain doctor` lists the checks), then: cybertrain setup && #{recovery_command(dir)}"
62
+ return false
57
63
  end
58
64
 
59
65
  puts "run spin lock && spin run gen"
60
66
  return true if system(bootstrap_command(dir))
61
67
 
62
- puts "error: bootstrapping #{dir} failed; fix the cause, then run: #{bootstrap_command(dir)}"
68
+ puts "error: bootstrapping #{dir} failed; fix the cause, then run: #{recovery_command(dir)}"
63
69
  false
64
70
  end
65
71
 
72
+ # Run by bootstrap once Toolchain.ensure! has put spin on this
73
+ # process's PATH.
66
74
  def self.bootstrap_command(dir)
67
- "cd #{shell_quote(dir)} && spin lock && spin run gen"
75
+ "cd #{Build.shell_quote(dir)} && spin lock && spin run gen"
68
76
  end
69
77
 
70
- def self.shell_quote(text)
71
- "'#{text.gsub("'", "'\\\\''")}'"
78
+ # The same steps for the user to run by hand: through `cybertrain
79
+ # spin`, since the Spinel cybertrain installed is not on their PATH.
80
+ def self.recovery_command(dir)
81
+ "cd #{Build.shell_quote(dir)} && cybertrain spin lock && cybertrain spin run gen"
72
82
  end
73
83
  end
74
84
  end
@@ -2,6 +2,7 @@
2
2
  # migration, model, controller and views of a resource and adds
3
3
  # `resources :posts` to config/routes.rb, the way Rails' scaffold does.
4
4
  require "cybertrain/generator/inflector"
5
+ require "cybertrain/ident"
5
6
  require "cybertrain/cli/templates"
6
7
 
7
8
  module Cybertrain
@@ -15,17 +16,12 @@ module Cybertrain
15
16
  TYPES = %w[string text integer float boolean date datetime references]
16
17
 
17
18
  # Columns every table already has (the primary key and t.timestamps).
19
+ # Everything else a column may not be called -- Ruby keywords, names
20
+ # that collide with the generated model -- comes from Cybertrain::Ident,
21
+ # the same rules the generator applies, so the scaffold refuses the
22
+ # name instead of writing files that `spin run gen` then rejects.
18
23
  RESERVED_COLUMNS = %w[id created_at updated_at]
19
24
 
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
25
  attr_reader :field_name, :field_type
30
26
 
31
27
  def self.parse(arg)
@@ -38,11 +34,18 @@ module Cybertrain
38
34
  type = parts.size > 1 ? parts[1].to_s : "string"
39
35
  type = "references" if type == "belongs_to"
40
36
  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
37
  raise InvalidArgument, "unknown type '#{type}' for #{name} (use #{TYPES.join(", ")})" unless TYPES.include?(type)
43
38
 
44
39
  field = Field.new(name, type)
45
40
  raise InvalidArgument, "'#{field.column_name}' is a column every table already has" if RESERVED_COLUMNS.include?(field.column_name)
41
+ # Ident.unusable_reason is the one definition of a name the scaffold
42
+ # must not invent; Scaffold.refuse_unusable! turns its answer into the
43
+ # message. A name that only shadows an Object method is accepted, as the
44
+ # generator accepts it, and Scaffold.generate prints a note.
45
+ Scaffold.refuse_unusable!(field.column_name, "field")
46
+ # A references field also defines the reader `def <name>` (post:references
47
+ # -> def post), which answers to the same rules as a column.
48
+ Scaffold.refuse_unusable!(name, "reference") if field.reference?
46
49
 
47
50
  field
48
51
  end
@@ -92,6 +95,13 @@ module Cybertrain
92
95
  @fields = fields
93
96
  end
94
97
 
98
+ # The index route helper's name without _path, as Rails' scaffold
99
+ # names it: the plural, or "<plural>_index" for a word that is its own
100
+ # plural (sheep_index_path), matching the routes DSL.
101
+ def index_helper
102
+ @plural == @singular ? "#{@plural}_index" : @plural
103
+ end
104
+
95
105
  # The column the model validates for presence ("" when there is none).
96
106
  def first_string_field
97
107
  @fields.each { |f| return f.field_name if f.field_type == "string" }
@@ -102,6 +112,36 @@ module Cybertrain
102
112
  module Scaffold
103
113
  DRAW_LINE = "Cybertrain::Routes.draw do"
104
114
 
115
+ # Policy: the scaffold refuses what the generator would RENAME (Ruby
116
+ # keywords, Ident::RESERVED_COLUMN_NAMES): its views and controller call
117
+ # the reader by the field's name, so a renamed reader would break them.
118
+ # It accepts what the generator only annotates (a name that shadows an
119
+ # Object method, `method`, `display`, `send`) and says so on stdout
120
+ # (shadow_notes), as the generator does in the model file.
121
+ #
122
+ # Raises InvalidArgument when `name` is a name the scaffold must not
123
+ # invent, with the message for the reason Ident.unusable_reason gives;
124
+ # `what` says what the name would name ("field", "reference",
125
+ # "resource"). The one place that maps a reason to a message, shared by the
126
+ # field column, the references reader and the resource singular: a
127
+ # reason Ident adds must be added here, and the `else` raises for one
128
+ # that is not (a `case` without it would return nil and let the name
129
+ # through silently). Returns nil, explicitly, for a usable name (NOTES
130
+ # rules 10/34: one return type).
131
+ def self.refuse_unusable!(name, what)
132
+ case Ident.unusable_reason(name)
133
+ when ""
134
+ nil
135
+ when "keyword"
136
+ raise InvalidArgument, "'#{name}' is a Ruby keyword and cannot name a #{what}"
137
+ when "reserved"
138
+ raise InvalidArgument, "'#{name}' would shadow a method of the generated model (Cybertrain::Model); pick another #{what}"
139
+ else
140
+ raise RuntimeError, "Ident.unusable_reason('#{name}') answered a reason Scaffold.refuse_unusable! has no message for"
141
+ end
142
+ nil
143
+ end
144
+
105
145
  # Returns the paths it created (files that already exist are reported
106
146
  # as "identical" or "exist" and left alone). Raises InvalidArgument on a
107
147
  # bad name, a bad or duplicate field, or a routes file without a
@@ -113,7 +153,18 @@ module Cybertrain
113
153
 
114
154
  underscored = Inflector.underscore(name)
115
155
  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))
156
+ singular = Inflector.singularize(underscored)
157
+ # Policy (refuse_unusable!): the scaffold refuses a singular the
158
+ # generator would RENAME. A reserved one (update, save) would give
159
+ # every referencing model a `<stem>_as_<col>` reader, and the singular
160
+ # is also the model class name. A shadowing one (display, tap) is
161
+ # accepted with a note (shadow_notes). The generator itself still
162
+ # generates a schema with a reserved table (a hand-written table named
163
+ # `updates` works), so this is the scaffold's choice, not the
164
+ # generator's refusal. The plural (the has_many side) is left to the
165
+ # generator, which renames such a reader (`errors_as_post`) -- and
166
+ # nothing may reference the table.
167
+ Scaffold.refuse_unusable!(singular, "resource")
117
168
 
118
169
  parsed = Array.new(0) { Field.new("", "") }
119
170
  columns = Array.new(0) { "" }
@@ -138,9 +189,25 @@ module Cybertrain
138
189
  record(created, root, "#{views}/edit.html.erb", Templates.edit_view(res))
139
190
  record(created, root, "#{views}/_form.html.erb", Templates.form_partial(res))
140
191
  add_route(routes_path, res.plural)
192
+ shadow_notes(res).each { |note| puts note }
141
193
  created
142
194
  end
143
195
 
196
+ # One line per name the scaffold accepted that shadows an Object method
197
+ # (Ident::SHADOWING_COLUMN_NAMES), the generator's own note told on
198
+ # stdout: a field (or references reader) on the model, the resource
199
+ # singular on every model that references it.
200
+ def self.shadow_notes(res)
201
+ notes = Array.new(0) { "" }
202
+ res.fields.each do |f|
203
+ notes << "note: '#{f.field_name}' shadows Object##{f.field_name} on the generated model; call it as #{res.singular}.#{f.field_name}" if Ident.shadowing_column?(f.field_name)
204
+ end
205
+ if Ident.shadowing_column?(res.singular)
206
+ notes << "note: '#{res.singular}' shadows Object##{res.singular} on a model that references #{res.plural} (belongs_to reader); call it as record.#{res.singular}"
207
+ end
208
+ notes
209
+ end
210
+
144
211
  def self.record(created, root, path, content)
145
212
  created << path if Templates.write(root, path, content) == "create"
146
213
  nil
@@ -5,6 +5,8 @@
5
5
  # The scaffold templates take a Cybertrain::CLI::Resource (cli/scaffold.rb).
6
6
  # Generated views stay inside the template language of docs/design.md
7
7
  # section 7: literals, @ivars, locals, helper calls, `if` and `each do`.
8
+ require "cybertrain/ident"
9
+
8
10
  module Cybertrain
9
11
  module CLI
10
12
  module Templates
@@ -39,19 +41,12 @@ module Cybertrain
39
41
  nil
40
42
  end
41
43
 
42
- # A lowercase Ruby-ish name: "post", "blog_post", "released_on".
44
+ # A lowercase Ruby-ish name: "post", "blog_post", "released_on". The
45
+ # snake_case subset of Ident.column? (what the generator accepts),
46
+ # letter-first since it names packages, binaries and files too; one
47
+ # definition of the characters, so the two cannot drift apart.
43
48
  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
49
+ Ident.snake_case?(word, false)
55
50
  end
56
51
 
57
52
  # "blog_posts" -> "Blog posts"
@@ -92,17 +87,17 @@ module Cybertrain
92
87
 
93
88
  ```sh
94
89
  cybertrain generate scaffold post title:string body:text
95
- cybertrain migration # gen, apply db/migrate, gen again
90
+ cybertrain db migrate # gen, apply db/migrate, gen again
96
91
  cybertrain server # http://127.0.0.1:3000 (`cybertrain server 4000` for another port)
97
92
  cybertrain build # dist/: the binary (views embedded) + public/
98
93
  ```
99
94
 
100
- `spin run gen` (which `migration`, `server` and `build` run for you)
95
+ `spin run gen` (which `db`, `server` and `build` run for you)
101
96
  regenerates `gen/` from db/schema.rb, config/routes.rb and app/;
102
97
  commit `gen/`. In development views under `app/views/` are read at
103
98
  run time: edit them without rebuilding. `cybertrain db status` and
104
99
  `cybertrain db rollback 1` reach the other database commands.
105
- `cybertrain migration` runs migrations through `bin/db.rb`; a
100
+ `cybertrain db migrate` runs migrations through `bin/db.rb`; a
106
101
  deployed `dist/#{package}` runs them with `./#{package} migrate`.
107
102
 
108
103
  To deploy, copy `dist/` to a machine with the same OS and CPU, then
@@ -115,7 +110,8 @@ module Cybertrain
115
110
  <<~RUBY
116
111
  # Application settings; see Cybertrain::Config for every option.
117
112
  # Environment variables (PORT, CYBERTRAIN_ENV, CYBERTRAIN_DATABASE,
118
- # CYBERTRAIN_SECRET_KEY_BASE) are read before this block runs.
113
+ # CYBERTRAIN_SECRET_KEY_BASE, CYBERTRAIN_HOST, CYBERTRAIN_SESSION_SAME_SITE,
114
+ # CYBERTRAIN_SESSION_PARTITIONED) are read before this block runs.
119
115
  Cybertrain.configure do |c|
120
116
  # c.port = 3000
121
117
  # c.workers = 1
@@ -326,7 +322,7 @@ module Cybertrain
326
322
  def destroy
327
323
  @#{s}.destroy
328
324
  flash[:notice] = "#{human} was successfully destroyed."
329
- redirect_to #{p}_path, status: :see_other
325
+ redirect_to #{res.index_helper}_path, status: :see_other
330
326
  end
331
327
 
332
328
  private
@@ -371,7 +367,7 @@ module Cybertrain
371
367
  end
372
368
  buf << "<p>\n"
373
369
  buf << " <%= link_to \"Edit this #{words}\", edit_#{s}_path(@#{s}) %> |\n"
374
- buf << " <%= link_to \"Back to #{res.plural.tr("_", " ")}\", #{res.plural}_path %>\n"
370
+ buf << " <%= link_to \"Back to #{res.plural.tr("_", " ")}\", #{res.index_helper}_path %>\n"
375
371
  buf << "</p>\n\n"
376
372
  buf << "<%= button_to \"Destroy this #{words}\", #{s}_path(@#{s}), method: :delete %>\n"
377
373
  buf
@@ -382,7 +378,7 @@ module Cybertrain
382
378
  buf = +""
383
379
  buf << "<h1>New #{s.tr("_", " ")}</h1>\n\n"
384
380
  buf << "<%= render \"form\", #{s}: @#{s} %>\n\n"
385
- buf << "<p><%= link_to \"Back to #{res.plural.tr("_", " ")}\", #{res.plural}_path %></p>\n"
381
+ buf << "<p><%= link_to \"Back to #{res.plural.tr("_", " ")}\", #{res.index_helper}_path %></p>\n"
386
382
  buf
387
383
  end
388
384
 
@@ -393,7 +389,7 @@ module Cybertrain
393
389
  buf << "<%= render \"form\", #{s}: @#{s} %>\n\n"
394
390
  buf << "<p>\n"
395
391
  buf << " <%= link_to \"Show this #{s.tr("_", " ")}\", #{s}_path(@#{s}) %> |\n"
396
- buf << " <%= link_to \"Back to #{res.plural.tr("_", " ")}\", #{res.plural}_path %>\n"
392
+ buf << " <%= link_to \"Back to #{res.plural.tr("_", " ")}\", #{res.index_helper}_path %>\n"
397
393
  buf << "</p>\n"
398
394
  buf
399
395
  end