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.
- checksums.yaml +4 -4
- data/.rubocop.yml +0 -2
- data/CHANGELOG.md +46 -1
- data/Gemfile +4 -2
- data/README.md +5 -1
- data/exe/rad +41 -0
- data/lib/radical/app.rb +61 -27
- data/lib/radical/asset.rb +24 -0
- data/lib/radical/asset_compiler.rb +40 -0
- data/lib/radical/assets.rb +45 -0
- data/lib/radical/controller.rb +54 -11
- data/lib/radical/database.rb +43 -44
- data/lib/radical/env.rb +1 -0
- data/lib/radical/flash.rb +61 -0
- data/lib/radical/form.rb +75 -15
- data/lib/radical/generator/app/.env +5 -0
- data/lib/radical/generator/app/Gemfile +7 -0
- data/lib/radical/generator/app/app.rb +37 -0
- data/lib/radical/generator/app/config.ru +5 -0
- data/lib/radical/generator/app/controllers/controller.rb +4 -0
- data/lib/radical/generator/app/models/model.rb +4 -0
- data/lib/radical/generator/app/routes.rb +5 -0
- data/lib/radical/generator/blank_migration.rb +11 -0
- data/lib/radical/generator/controller.rb +59 -0
- data/lib/radical/generator/migration.rb +13 -0
- data/lib/radical/generator/model.rb +9 -0
- data/lib/radical/generator/views/_form.rb +6 -0
- data/lib/radical/generator/views/edit.rb +3 -0
- data/lib/radical/generator/views/index.rb +24 -0
- data/lib/radical/generator/views/new.rb +4 -0
- data/lib/radical/generator/views/show.rb +5 -0
- data/lib/radical/generator.rb +155 -0
- data/lib/radical/migration.rb +45 -0
- data/lib/radical/model.rb +3 -12
- data/lib/radical/router.rb +143 -42
- data/lib/radical/routes.rb +59 -0
- data/lib/radical/security_headers.rb +27 -0
- data/lib/radical/strings.rb +17 -0
- data/lib/radical/table.rb +2 -0
- data/lib/radical/view.rb +19 -9
- data/lib/radical.rb +11 -0
- data/radical.gemspec +4 -3
- 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
|
-
@
|
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
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
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
|
20
|
-
|
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(
|
24
|
-
|
45
|
+
def button(attrs = {}, &block)
|
46
|
+
tag 'button', attrs, &block
|
25
47
|
end
|
26
48
|
|
27
|
-
def submit(
|
28
|
-
|
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
|
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
|
-
|
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,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,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,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,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
|
|