radical 1.0.0 → 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 (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