radical 1.0.0 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +0 -2
  3. data/CHANGELOG.md +46 -1
  4. data/Gemfile +4 -2
  5. data/README.md +5 -1
  6. data/exe/rad +41 -0
  7. data/lib/radical/app.rb +61 -27
  8. data/lib/radical/asset.rb +24 -0
  9. data/lib/radical/asset_compiler.rb +40 -0
  10. data/lib/radical/assets.rb +45 -0
  11. data/lib/radical/controller.rb +54 -11
  12. data/lib/radical/database.rb +43 -44
  13. data/lib/radical/env.rb +1 -0
  14. data/lib/radical/flash.rb +61 -0
  15. data/lib/radical/form.rb +75 -15
  16. data/lib/radical/generator/app/.env +5 -0
  17. data/lib/radical/generator/app/Gemfile +7 -0
  18. data/lib/radical/generator/app/app.rb +37 -0
  19. data/lib/radical/generator/app/config.ru +5 -0
  20. data/lib/radical/generator/app/controllers/controller.rb +4 -0
  21. data/lib/radical/generator/app/models/model.rb +4 -0
  22. data/lib/radical/generator/app/routes.rb +5 -0
  23. data/lib/radical/generator/blank_migration.rb +11 -0
  24. data/lib/radical/generator/controller.rb +59 -0
  25. data/lib/radical/generator/migration.rb +13 -0
  26. data/lib/radical/generator/model.rb +9 -0
  27. data/lib/radical/generator/views/_form.rb +6 -0
  28. data/lib/radical/generator/views/edit.rb +3 -0
  29. data/lib/radical/generator/views/index.rb +24 -0
  30. data/lib/radical/generator/views/new.rb +4 -0
  31. data/lib/radical/generator/views/show.rb +5 -0
  32. data/lib/radical/generator.rb +155 -0
  33. data/lib/radical/migration.rb +45 -0
  34. data/lib/radical/model.rb +3 -12
  35. data/lib/radical/router.rb +143 -42
  36. data/lib/radical/routes.rb +59 -0
  37. data/lib/radical/security_headers.rb +27 -0
  38. data/lib/radical/strings.rb +17 -0
  39. data/lib/radical/table.rb +2 -0
  40. data/lib/radical/view.rb +19 -9
  41. data/lib/radical.rb +11 -0
  42. data/radical.gemspec +4 -3
  43. metadata +44 -17
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Radical
4
+ class Flash
5
+ class SessionUnavailable < StandardError; end
6
+
7
+ SESSION_KEY = 'rack.session'
8
+ FLASH_KEY = '__FLASH__'
9
+
10
+ class FlashHash
11
+ def initialize(session)
12
+ raise SessionUnavailable, 'No session variable found. Requires Rack::Session' unless session
13
+
14
+ @session = session
15
+ end
16
+
17
+ def [](key)
18
+ hash[key] ||= session.delete(key)
19
+ end
20
+
21
+ def []=(key, value)
22
+ hash[key] = session[key] = value
23
+ end
24
+
25
+ def mark!
26
+ @flagged = session.keys
27
+ end
28
+
29
+ def clear!
30
+ @flagged.each { |k| session.delete(k) }
31
+ @flagged.clear
32
+ end
33
+
34
+ private
35
+
36
+ def hash
37
+ @hash ||= {}
38
+ end
39
+
40
+ def session
41
+ @session[FLASH_KEY] ||= {}
42
+ end
43
+ end
44
+
45
+ def initialize(app)
46
+ @app = app
47
+ end
48
+
49
+ def call(env)
50
+ flash_hash ||= FlashHash.new(env[SESSION_KEY])
51
+
52
+ flash_hash.mark!
53
+
54
+ res = @app.call(env)
55
+
56
+ flash_hash.clear!
57
+
58
+ res
59
+ end
60
+ end
61
+ end
data/lib/radical/form.rb CHANGED
@@ -1,35 +1,66 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'rack/csrf'
2
4
 
3
5
  module Radical
4
6
  class Form
7
+ SELF_CLOSING_TAGS = %w[
8
+ area
9
+ base
10
+ br
11
+ col
12
+ embed
13
+ hr
14
+ img
15
+ input
16
+ keygen
17
+ link
18
+ meta
19
+ param
20
+ source
21
+ track
22
+ wbr
23
+ ].freeze
24
+
5
25
  def initialize(options, controller)
6
26
  @model = options[:model]
7
27
  @controller = controller
8
- @route_name = @controller.class.route_name
9
- @override_method = options[:method]&.upcase || (@model.saved? ? 'PATCH' : 'POST')
28
+ @override_method = options[:method]&.upcase || (@model&.saved? ? 'PATCH' : 'POST')
10
29
  @method = %w[GET POST].include?(@override_method) ? @override_method : 'POST'
30
+ @action = options[:action] || action_from(model: @model, controller: controller)
31
+ end
11
32
 
12
- @action = if @model.saved?
13
- @controller.public_send(:"#{@route_name}_path", @model)
14
- else
15
- @controller.public_send(:"#{@route_name}_path")
16
- end
33
+ def text(name, attrs = {})
34
+ attrs.merge!(type: 'text', name: name, value: @model&.public_send(name))
35
+
36
+ tag 'input', attrs
17
37
  end
18
38
 
19
- def text(name)
20
- "<input type=text name=#{@route_name}[#{name}] value=\"#{@model.public_send(name)}\" />"
39
+ def number(name, attrs = {})
40
+ attrs.merge!(type: 'number', name: name, value: @model&.public_send(name))
41
+
42
+ tag 'input', attrs
21
43
  end
22
44
 
23
- def button(name)
24
- "<button type=submit>#{name}</button>"
45
+ def button(attrs = {}, &block)
46
+ tag 'button', attrs, &block
25
47
  end
26
48
 
27
- def submit(value)
28
- "<input type=submit value=#{value} />"
49
+ def submit(value_or_attrs = {})
50
+ attrs = {}
51
+
52
+ case value_or_attrs
53
+ when String
54
+ attrs[:value] = value_or_attrs
55
+ when Hash
56
+ attrs = value_or_attrs || {}
57
+ end
58
+
59
+ tag 'input', attrs.merge('type' => 'submit')
29
60
  end
30
61
 
31
62
  def open_tag
32
- "<form action=#{@action} method=#{@method}>"
63
+ "<form #{html_attributes(action: @action, method: @method)}>"
33
64
  end
34
65
 
35
66
  def csrf_tag
@@ -37,11 +68,40 @@ module Radical
37
68
  end
38
69
 
39
70
  def rack_override_tag
40
- "<input type=hidden name=_method value=#{@override_method} />" unless %w[GET POST].include?(@override_method)
71
+ attrs = { value: @override_method, type: 'hidden', name: '_method' }
72
+
73
+ tag('input', attrs) unless %w[GET POST].include?(@override_method)
41
74
  end
42
75
 
43
76
  def close_tag
44
77
  '</form>'
45
78
  end
79
+
80
+ private
81
+
82
+ def tag(name, attrs, &block)
83
+ attr_string = attrs.empty? ? '' : " #{html_attributes(attrs)}"
84
+ open_tag = "<#{name}"
85
+ self_closing = SELF_CLOSING_TAGS.include?(name)
86
+ end_tag = self_closing ? ' />' : "</#{name}>"
87
+
88
+ "#{open_tag}#{attr_string}#{self_closing ? '' : '>'}#{block&.call}#{end_tag}"
89
+ end
90
+
91
+ def html_attributes(options = {})
92
+ options.transform_keys(&:to_s).sort_by { |k, _| k }.map { |k, v| "#{k}=\"#{v}\"" }.join(' ')
93
+ end
94
+
95
+ def action_from(controller:, model:)
96
+ return if model.nil?
97
+
98
+ route_name = controller.class.route_name
99
+
100
+ if model.saved?
101
+ controller.send(:"#{route_name}_path", model)
102
+ else
103
+ controller.send(:"#{route_name}_path")
104
+ end
105
+ end
46
106
  end
47
107
  end
@@ -0,0 +1,5 @@
1
+ <<-RB
2
+ RADICAL_ENV=development
3
+ SESSION_SECRET=#{SecureRandom.hex(32)}
4
+ DATABASE_URL=development.sqlite3
5
+ RB
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
6
+
7
+ gem 'radical', '~> 1.0'
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'radical'
4
+
5
+ def require_all(*args)
6
+ args.each do |arg|
7
+ file = File.join(__dir__, arg)
8
+
9
+ if File.exist?("#{file}.rb")
10
+ require file
11
+ else
12
+ Dir[File.join(file, '*.rb')].sort.each do |f|
13
+ require f
14
+ end
15
+ end
16
+ end
17
+ end
18
+
19
+ require_all(
20
+ 'models/model',
21
+ 'models',
22
+ 'controllers/controller',
23
+ 'controllers',
24
+ 'routes'
25
+ )
26
+
27
+ # the main entry point into the application
28
+ class App < Radical::App
29
+ routes Routes
30
+
31
+ assets do |a|
32
+ a.css []
33
+ a.js []
34
+ end
35
+
36
+ compile_assets if Radical.env.production?
37
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'app'
4
+
5
+ run App
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Controller < Radical::Controller
4
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Model < Radical::Model
4
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Routes < Radical::Routes
4
+ # root :Home
5
+ end
@@ -0,0 +1,11 @@
1
+ <<~RB
2
+ # frozen_string_literal: true
3
+
4
+ class #{@name.capitalize} < Radical::Migration
5
+ up do
6
+ end
7
+
8
+ down do
9
+ end
10
+ end
11
+ RB
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ <<~RB
4
+ # frozen_string_literal: true
5
+
6
+ class #{plural_constant} < Controller
7
+ def index
8
+ @#{plural} = #{singular_constant}.all
9
+ end
10
+
11
+ def show; end
12
+
13
+ def new
14
+ @#{singular} = #{singular_constant}.new
15
+ end
16
+
17
+ def create
18
+ @#{singular} = #{singular_constant}.new(#{singular}_params)
19
+
20
+ if @#{singular}.save
21
+ flash[:success] = '#{singular_constant} created'
22
+ redirect #{plural}_path
23
+ else
24
+ render :new
25
+ end
26
+ end
27
+
28
+ def edit; end
29
+
30
+ def update
31
+ if #{singular}.update(#{singular}_params)
32
+ flash[:success] = '#{singular_constant} updated'
33
+ redirect #{plural}_path
34
+ else
35
+ render :edit
36
+ end
37
+ end
38
+
39
+ def destroy
40
+ if #{singular}.destroy
41
+ flash[:success] = '#{singular_constant} destroyed'
42
+ else
43
+ flash[:error] = 'Error destroying #{singular_constant}'
44
+ end
45
+
46
+ redirect #{plural}_path
47
+ end
48
+
49
+ private
50
+
51
+ def #{singular}_params
52
+ params['#{singular}'].slice(#{params})
53
+ end
54
+
55
+ def #{singular}
56
+ @#{singular} = #{singular_constant}.find(params['id'])
57
+ end
58
+ end
59
+ RB
@@ -0,0 +1,13 @@
1
+ <<~RB
2
+ # frozen_string_literal: true
3
+
4
+ class CreateTable#{plural_constant} < Radical::Migration
5
+ change do
6
+ create_table :#{plural} do |t|
7
+ #{columns(leading: 8)}
8
+
9
+ t.timestamps
10
+ end
11
+ end
12
+ end
13
+ RB
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ <<~RB
4
+ # frozen_string_literal: true
5
+
6
+ class #{singular_constant} < Model
7
+ table '#{plural}'
8
+ end
9
+ RB
@@ -0,0 +1,6 @@
1
+ <<~ERB
2
+ <%== form model: #{singular} do |f| %>
3
+ #{inputs(leading: 4)}
4
+ <%== f.submit 'Save' %>
5
+ <% end %>
6
+ ERB
@@ -0,0 +1,3 @@
1
+ <<~ERB
2
+ <%== partial 'form' %>
3
+ ERB
@@ -0,0 +1,24 @@
1
+ <<~ERB
2
+ <a href="<%= new_#{plural}_path %>">New #{singular}</a>
3
+
4
+ <table>
5
+ <thead>
6
+ #{th(leading: 6)}
7
+ <th></th>
8
+ </thead>
9
+ <tbody>
10
+ <% @#{plural}.each do |#{singular}| %>
11
+ <tr>
12
+ #{td(leading: 10)}
13
+ <td>
14
+ <a href="<%= #{plural}_path(#{singular}) %>">show</a>
15
+ <a href="<%= edit_#{plural}_path(#{singular}) %>">edit</a>
16
+ <%== form model: #{singular}, method: :delete do |f| %>
17
+ <%== f.submit 'delete' %>
18
+ <% end %>
19
+ </td>
20
+ </tr>
21
+ <% end %>
22
+ </tbody>
23
+ </table>
24
+ ERB
@@ -0,0 +1,4 @@
1
+ <<~ERB
2
+ <%== partial 'form' %>
3
+ ERB
4
+
@@ -0,0 +1,5 @@
1
+ <<~ERB
2
+ <%= #{singular}.name %>
3
+
4
+ <a href="<%== #{plural}_path %>">Index</a>
5
+ ERB
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'securerandom'
5
+
6
+ module Radical
7
+ class Generator
8
+ def initialize(name, props)
9
+ @name = name
10
+ @props = props
11
+ end
12
+
13
+ def mvc
14
+ migration
15
+ model
16
+ views
17
+ controller
18
+ end
19
+
20
+ def migration(model: true)
21
+ dir = File.join(Dir.pwd, 'migrations')
22
+ FileUtils.mkdir_p dir
23
+
24
+ template = instance_eval File.read(File.join(__dir__, 'generator', "#{model ? '' : 'blank_'}migration.rb"))
25
+ migration_name = model ? "#{Time.now.to_i}_create_table_#{plural}.rb" : "#{Time.now.to_i}_#{@name}.rb"
26
+ filename = File.join(dir, migration_name)
27
+
28
+ write(filename, template)
29
+ end
30
+
31
+ def model
32
+ template = instance_eval File.read File.join(__dir__, 'generator', 'model.rb')
33
+ dir = File.join(Dir.pwd, 'models')
34
+ FileUtils.mkdir_p dir
35
+ filename = File.join(dir, "#{singular}.rb")
36
+
37
+ write(filename, template)
38
+ end
39
+
40
+ def controller
41
+ template = instance_eval File.read File.join(__dir__, 'generator', 'controller.rb')
42
+ dir = File.join(Dir.pwd, 'controllers')
43
+ FileUtils.mkdir_p dir
44
+ filename = File.join(dir, "#{plural}.rb")
45
+
46
+ write(filename, template)
47
+ end
48
+
49
+ def views
50
+ dir = File.join(Dir.pwd, 'views', plural)
51
+ FileUtils.mkdir_p dir
52
+
53
+ Dir[File.join(__dir__, 'generator', 'views', '*.rb')].sort.each do |template|
54
+ contents = instance_eval File.read template
55
+ filename = File.join(dir, "#{File.basename(template, '.rb')}.erb")
56
+
57
+ write(filename, contents)
58
+ end
59
+ end
60
+
61
+ def app
62
+ @name = nil if @name == '.'
63
+ parts = [Dir.pwd, @name].compact
64
+ dir = File.join(*parts)
65
+ FileUtils.mkdir_p dir
66
+
67
+ %w[
68
+ assets/css
69
+ assets/js
70
+ controllers
71
+ migrations
72
+ models
73
+ views
74
+ ].each do |dir_|
75
+ puts "Creating directory #{dir_}"
76
+ FileUtils.mkdir_p File.join(dir, dir_)
77
+ end
78
+
79
+ Dir[File.join(__dir__, 'generator', 'app', '**', '*.*')].sort.each do |template|
80
+ contents = File.read(template)
81
+ filename = File.join(dir, File.path(template).gsub("#{__dir__}/generator/app/", ''))
82
+
83
+ write(filename, contents)
84
+ end
85
+
86
+ # Explicitly include .env
87
+ template = File.join(__dir__, 'generator', 'app', '.env')
88
+ contents = instance_eval File.read(template)
89
+ filename = File.join(dir, '.env')
90
+ write(filename, contents)
91
+ end
92
+
93
+ private
94
+
95
+ def write(filename, contents)
96
+ if File.exist?(filename)
97
+ puts "Skipped #{File.basename(filename)}"
98
+ else
99
+ File.write(filename, contents)
100
+ puts "Created #{filename}"
101
+ end
102
+ end
103
+
104
+ def singular_constant
105
+ @name.gsub(/\(.*\)/, '')
106
+ end
107
+
108
+ def plural_constant
109
+ @name.gsub(/[)(]/, '')
110
+ end
111
+
112
+ def singular
113
+ Strings.camel_case singular_constant
114
+ end
115
+
116
+ def plural
117
+ Strings.camel_case plural_constant
118
+ end
119
+
120
+ def columns(leading:)
121
+ @props
122
+ .map { |p| p.split(':') }
123
+ .map { |name, type| "t.#{type} #{name}" }
124
+ .join "#{' ' * leading}\n"
125
+ end
126
+
127
+ def th(leading:)
128
+ @props
129
+ .map { |p| p.split(':').first }
130
+ .map { |name| "<th>#{name}</th>" }
131
+ .join "#{' ' * leading}\n"
132
+ end
133
+
134
+ def td(leading:)
135
+ @props
136
+ .map { |p| p.split(':').first }
137
+ .map { |name| "<td><%= #{singular}.#{name} %></td>" }
138
+ .join "#{' ' * leading}\n"
139
+ end
140
+
141
+ def inputs(leading:)
142
+ @props
143
+ .map { |p| p.split(':').first }
144
+ .map { |name| "<%== f.text :#{name} %>" }
145
+ .join "#{' ' * leading}\n"
146
+ end
147
+
148
+ def params
149
+ @props
150
+ .map { |p| p.split(':').first }
151
+ .map { |name| "'#{name}'" }
152
+ .join ', '
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Radical
4
+ class Migration
5
+ class << self
6
+ def change(&block)
7
+ @change = block
8
+ end
9
+
10
+ def up(&block)
11
+ @up = block
12
+ end
13
+
14
+ def down(&block)
15
+ @down = block
16
+ end
17
+
18
+ def create_table(name, &block)
19
+ return drop_table(name) if @change && @rollback
20
+
21
+ table = Table.new(name)
22
+
23
+ block.call(table)
24
+
25
+ "create table #{name} ( id integer primary key, #{table.columns.join(',')} )"
26
+ end
27
+
28
+ def drop_table(name)
29
+ "drop table #{name}"
30
+ end
31
+
32
+ def migrate!(db:, version:)
33
+ db.execute(@change&.call || @up&.call)
34
+ db.execute 'insert into radical_migrations (version) values (?)', [version]
35
+ end
36
+
37
+ def rollback!(db:, version:)
38
+ @rollback = true
39
+
40
+ db.execute(@change&.call || @down&.call)
41
+ db.execute 'delete from radical_migrations where version = ?', [version]
42
+ end
43
+ end
44
+ end
45
+ end
data/lib/radical/model.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'database'
2
4
 
3
5
  module Radical
@@ -7,17 +9,6 @@ module Radical
7
9
  class << self
8
10
  attr_accessor :table_name
9
11
 
10
- def database(name)
11
- conn = SQLite3::Database.new name
12
- conn.results_as_hash = true
13
- conn.type_translation = true
14
- Database.connection = conn
15
- end
16
-
17
- def prepend_migrations_path(path)
18
- Database.migrations_path = path
19
- end
20
-
21
12
  def db
22
13
  Database.connection
23
14
  end
@@ -58,7 +49,7 @@ module Radical
58
49
  def initialize(params = {})
59
50
  columns.each do |column|
60
51
  self.class.attr_accessor column.to_sym
61
- instance_variable_set "@#{column}", params[column]
52
+ instance_variable_set "@#{column}", (params[column] || params[column.to_sym])
62
53
  end
63
54
  end
64
55