amber_component 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rake/testtask'
5
+
6
+ ::Rake::TestTask.new(:test) do |t|
7
+ t.libs << 'lib'
8
+ t.libs << 'test'
9
+ t.libs << 'test/fixtures'
10
+ # ignore tests of rails apps
11
+ t.test_files = ::FileList['test/**/*_test.rb'] - ::FileList['test/apps/**/*_test.rb']
12
+ end
13
+
14
+ require 'rubocop/rake_task'
15
+
16
+ ::RuboCop::RakeTask.new
17
+
18
+ task default: %i[test rubocop]
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/amber_component/version"
4
+
5
+ ::Gem::Specification.new do |spec|
6
+ spec.name = "amber_component"
7
+ spec.version = ::AmberComponent::VERSION
8
+ spec.authors = ['Ruby-Amber', 'Mateusz Drewniak', 'Garbus Beach']
9
+ spec.email = ['matmg24@gmail.com', 'piotr.garbus.garbicz@gmail.com']
10
+
11
+ spec.summary = "A simple component library which seamlessly hooks into your Rails project."
12
+ spec.description = <<~DESC
13
+ A simple component library which seamlessly hooks into your Rails project
14
+ and allows you to create simple backend components.
15
+
16
+ They work like mini controllers which are bound with their view.
17
+ DESC
18
+ spec.homepage = 'https://github.com/amber-ruby/amber_component'
19
+ spec.license = 'MIT'
20
+ spec.required_ruby_version = ">= 2.6.0"
21
+
22
+ # spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'"
23
+
24
+ spec.metadata["homepage_uri"] = spec.homepage
25
+ spec.metadata["source_code_uri"] = spec.homepage
26
+ spec.metadata['rubygems_mfa_required'] = 'true'
27
+ # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
28
+
29
+ # Specify which files should be added to the gem when it is released.
30
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
31
+ spec.files = ::Dir.chdir(__dir__) do
32
+ `git ls-files -z`.split("\x0").reject do |f|
33
+ (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
34
+ end
35
+ end
36
+ spec.bindir = "exe"
37
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| ::File.basename(f) }
38
+ spec.require_paths = ["lib"]
39
+
40
+ # Uncomment to register a new dependency of your gem
41
+ spec.add_dependency "memery", ">= 1.4.1"
42
+ spec.add_dependency "rails", ">= 6"
43
+ spec.add_dependency "tilt", ">= 2.0.10"
44
+
45
+ # For more information and examples about making a new gem, check out our
46
+ # guide at: https://bundler.io/guides/creating_gem.html
47
+ end
@@ -0,0 +1,492 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'erb'
5
+ require 'tilt'
6
+ require 'active_model/callbacks'
7
+ require 'memery'
8
+
9
+ require_relative './style_injector'
10
+
11
+ # Abstract class which serves as a base
12
+ # for all Amber Components.
13
+ #
14
+ # There are a few life cycle callbacks that can be defined.
15
+ # The same way as in `ActiveRecord` models and `ActionController` controllers.
16
+ #
17
+ # - before_render
18
+ # - around_render
19
+ # - after_render
20
+ # - before_initialize
21
+ # - around_initialize
22
+ # - after_initialize
23
+ #
24
+ # class ButtonComponent < ::AmberComponent::Base
25
+ # # You can provide a Symbol of the method that should be called
26
+ # before_render :before_render_method
27
+ # # Or provide a block that will be executed
28
+ # after_initialize do
29
+ # # Your code here
30
+ # end
31
+ #
32
+ # def before_render_method
33
+ # # Your code here
34
+ # end
35
+ # end
36
+ #
37
+ #
38
+ # @abstract Create a subclass to define a new component.
39
+ module ::AmberComponent
40
+ class Base # :nodoc:
41
+ extend ::ActiveModel::Callbacks
42
+
43
+ include Helper
44
+
45
+ # @return [Regexp]
46
+ VIEW_FILE_REGEXP = /^view\./.freeze
47
+ # @return [Regexp]
48
+ STYLE_FILE_REGEXP = /^style\./.freeze
49
+
50
+ # View types with built-in embedded Ruby
51
+ #
52
+ # @return [Set<Symbol>]
53
+ VIEW_TYPES_WITH_RUBY = ::Set[:erb, :haml, :slim].freeze
54
+ # @return [Set<Symbol>]
55
+ ALLOWED_VIEW_TYPES = ::Set[:erb, :haml, :slim, :html, :md, :markdown].freeze
56
+ # @return [Set<Symbol>]
57
+ ALLOWED_STYLE_TYPES = ::Set[:sass, :scss, :less].freeze
58
+
59
+ class << self
60
+ include ::Memery
61
+
62
+ # @param kwargs [Hash{Symbol => Object}]
63
+ # @return [String]
64
+ def run(**kwargs, &block)
65
+ comp = new(**kwargs)
66
+
67
+ comp.render(&block)
68
+ end
69
+
70
+ alias call run
71
+
72
+
73
+ # @return [String]
74
+ memoize def asset_dir_path
75
+ class_const_name = name.split('::').last
76
+ component_file_path, = module_parent.const_source_location(class_const_name)
77
+
78
+ component_file_path.delete_suffix('.rb')
79
+ end
80
+
81
+ # @return [String]
82
+ def view_path
83
+ asset_path view_file_name
84
+ end
85
+
86
+ # @return [String, nil]
87
+ def view_file_name
88
+ files = asset_file_names(VIEW_FILE_REGEXP)
89
+ raise MultipleViews, "More than one view file for `#{name}` found!" if files.length > 1
90
+
91
+ files.first
92
+ end
93
+
94
+ # @return [Symbol]
95
+ def view_type
96
+ (view_file_name.split('.')[1..].reject { _1.match?(/erb/) }.last || 'erb')&.to_sym
97
+ end
98
+
99
+ # @return [String]
100
+ def style_path
101
+ asset_path style_file_name
102
+ end
103
+
104
+ # @return [String, nil]
105
+ def style_file_name
106
+ files = asset_file_names(STYLE_FILE_REGEXP)
107
+ raise MultipleStyles, "More than one style file for `#{name}` found!" if files.length > 1
108
+
109
+ files.first
110
+ end
111
+
112
+ # @return [Symbol]
113
+ def style_type
114
+ (style_file_name.split('.')[1..].reject { _1.match?(/erb/) }.last || 'erb')&.to_sym
115
+ end
116
+
117
+ # Memoize these methods in production
118
+ if defined?(::Rails) && ::Rails.env.production?
119
+ memoize :view_path
120
+ memoize :view_file_name
121
+ memoize :view_type
122
+
123
+ memoize :style_path
124
+ memoize :style_file_name
125
+ memoize :style_type
126
+ end
127
+
128
+ # Register an inline view by returning a String from the passed block.
129
+ #
130
+ # Usage:
131
+ #
132
+ # view do
133
+ # <<~ERB
134
+ # <h1>
135
+ # Hello <%= @name %>
136
+ # </h1>
137
+ # ERB
138
+ # end
139
+ #
140
+ # or:
141
+ #
142
+ # view :haml do
143
+ # <<~HAML
144
+ # %h1
145
+ # Hello
146
+ # = @name
147
+ # HAML
148
+ # end
149
+ #
150
+ # @param type [Symbol]
151
+ # @return [void]
152
+ def view(type = :erb, &block)
153
+ @method_view = TypedContent.new(type: type, content: block)
154
+ end
155
+
156
+ # ERB/Haml/Slim view registered through the `view` method.
157
+ #
158
+ # @return [TypedContent]
159
+ attr_reader :method_view
160
+
161
+ # Register an inline style by returning a String from the passed block.
162
+ #
163
+ # Usage:
164
+ #
165
+ # style do
166
+ # '.my-class { color: red; }'
167
+ # end
168
+ #
169
+ # or:
170
+ #
171
+ # style :sass do
172
+ # <<~SASS
173
+ # .my-class
174
+ # color: red
175
+ # SASS
176
+ # end
177
+ #
178
+ # @param type [Symbol]
179
+ # @return [void]
180
+ def style(type = :css, &block)
181
+ @method_style = TypedContent.new(type: type, content: block)
182
+ end
183
+
184
+ # CSS/SCSS/Sass styles registered through the `style` method.
185
+ #
186
+ # @return [TypedContent]
187
+ attr_reader :method_style
188
+
189
+ private
190
+
191
+ # @param subclass [Class]
192
+ # @return [void]
193
+ def inherited(subclass)
194
+ # @type [Module]
195
+ parent_module = subclass.module_parent
196
+ method_body = proc do |**kwargs, &block|
197
+ subclass.run(**kwargs, &block)
198
+ end
199
+
200
+ Helper.define_method(subclass.name, &method_body) && return if parent_module.equal? ::Object
201
+
202
+ parent_module.define_singleton_method(subclass.name.split('::').last, &method_body)
203
+ end
204
+
205
+ # @param file_name [String, nil]
206
+ # @return [String, nil]
207
+ def asset_path(file_name)
208
+ return unless file_name
209
+
210
+ ::File.join(asset_dir_path, file_name)
211
+ end
212
+
213
+ # Returns the name of the file inside the asset directory
214
+ # of this component that matches the provided `Regexp`
215
+ #
216
+ # @param type_regexp [Regexp]
217
+ # @return [Array<String>]
218
+ def asset_file_names(type_regexp)
219
+ return [] unless ::File.directory?(asset_dir_path)
220
+
221
+ ::Dir.entries(asset_dir_path).select do |file|
222
+ next unless ::File.file?(::File.join(asset_dir_path, file))
223
+
224
+ file.match? type_regexp
225
+ end
226
+ end
227
+ end
228
+
229
+ define_model_callbacks :initialize, :render
230
+
231
+ # @param kwargs [Hash{Symbol => Object}]
232
+ def initialize(**kwargs)
233
+ run_callbacks :initialize do
234
+ bind_variables(kwargs)
235
+ end
236
+ end
237
+
238
+ # @return [String]
239
+ def render(&block)
240
+ run_callbacks :render do
241
+ element = inject_views(&block)
242
+ styles = inject_styles
243
+ element += styles unless styles.nil?
244
+ element.html_safe
245
+ end
246
+ end
247
+
248
+ def render_in(context)
249
+ byebug
250
+ end
251
+
252
+ private
253
+
254
+ # @param kwargs [Hash{Symbol => Object}]
255
+ # @return [void]
256
+ def bind_variables(kwargs)
257
+ kwargs.each do |key, value|
258
+ instance_variable_set("@#{key}", value)
259
+ end
260
+ end
261
+
262
+ # Helper method to render view from string or with other provided type.
263
+ #
264
+ # Usage:
265
+ #
266
+ # render_custom_view('<h1>Hello World</h1>')
267
+ #
268
+ # or:
269
+ #
270
+ # render_custom_view content: '**Hello World**', type: 'md'
271
+ #
272
+ # @param style [TypedContent, Hash{Symbol => String, Symbol, Proc}, String]
273
+ # @return [String, nil]
274
+ def render_custom_view(view, &block)
275
+ return '' unless view
276
+ return view if view.is_a?(::String)
277
+
278
+ view = TypedContent.wrap(view)
279
+ type = view.type
280
+ content = view.to_s
281
+
282
+ if content.empty?
283
+ raise EmptyView, <<~ERR.squish
284
+ Custom view for `#{self.class}` from view method cannot be empty!
285
+ ERR
286
+ end
287
+
288
+ unless ALLOWED_VIEW_TYPES.include? type
289
+ raise UnknownViewType, <<~ERR.squish
290
+ Unknown view type for `#{self.class}` from view method!
291
+ Check return value of param type in `view :[type] do`
292
+ ERR
293
+ end
294
+
295
+ unless VIEW_TYPES_WITH_RUBY.include? type
296
+ # first render the content with ERB if the
297
+ # type does not support embedding Ruby by default
298
+ content = render_string(content, :erb, block)
299
+ end
300
+
301
+ render_string(content, type, block)
302
+ end
303
+
304
+ # @return [String]
305
+ def render_view_from_file(&block)
306
+ view_path = self.class.view_path
307
+ return '' if view_path.nil? || !::File.file?(view_path)
308
+
309
+ content = ::File.read(view_path)
310
+ type = self.class.view_type
311
+
312
+ unless VIEW_TYPES_WITH_RUBY.include? type
313
+ content = render_string(content, :erb, block)
314
+ end
315
+
316
+ render_string(content, type, block)
317
+ end
318
+
319
+ # Method returning view from method in class file.
320
+ # Usage:
321
+ #
322
+ # view do
323
+ # <<~HTML
324
+ # <h1>
325
+ # Hello <%= @name %>
326
+ # </h1>
327
+ # HTML
328
+ # end
329
+ #
330
+ # or:
331
+ #
332
+ # view :haml do
333
+ # <<~HAML
334
+ # %h1
335
+ # Hello
336
+ # = @name
337
+ # HAML
338
+ # end
339
+ #
340
+ # @return [String]
341
+ def render_class_method_view(&block)
342
+ render_custom_view(self.class.method_view, &block)
343
+ end
344
+
345
+ # Method returning view from params in view.
346
+ # Usage:
347
+ #
348
+ # <%= ExampleComponent data: data, view: "<h1>Hello #{@name}</h1>" %>
349
+ #
350
+ # or:
351
+ #
352
+ # <%= ExampleComponent data: data, view: { content: "<h1>Hello #{@name}</h1>", type: 'erb' } %>
353
+ #
354
+ # @return [String]
355
+ def render_view_from_inline(&block)
356
+ data = \
357
+ if @view.is_a? String
358
+ TypedContent.new(
359
+ type: :erb,
360
+ content: @view
361
+ )
362
+ else
363
+ @view
364
+ end
365
+
366
+ render_custom_view(data, &block)
367
+ end
368
+
369
+ # @return [String]
370
+ def inject_views(&block)
371
+ view_from_file = render_view_from_file(&block)
372
+ view_from_method = render_class_method_view(&block)
373
+ view_from_inline = render_view_from_inline(&block)
374
+
375
+ view_content = view_from_file unless view_from_file.empty?
376
+ view_content = view_from_method unless view_from_method.empty?
377
+ view_content = view_from_inline unless view_from_inline.empty?
378
+
379
+ if view_content.nil? || view_content.empty?
380
+ raise ViewFileNotFound, "View for `#{self.class}` could not be found!"
381
+ end
382
+
383
+ view_content
384
+ end
385
+
386
+ # Helper method to render style from css string or with other provided type.
387
+ #
388
+ # Usage:
389
+ #
390
+ # render_custom_style('.my-class { color: red; }')
391
+ #
392
+ # or:
393
+ #
394
+ # render_custom_style style: '.my-class { color: red; }', type: 'sass'
395
+ #
396
+ # @param style [TypedContent, Hash{Symbol => Symbol, String, Proc}, String]
397
+ # @return [String, nil]
398
+ def render_custom_style(style)
399
+ return '' unless style
400
+ return style if style.is_a?(::String)
401
+
402
+ style = TypedContent.wrap(style)
403
+ type = style.type
404
+ content = style.to_s
405
+
406
+ if content.empty?
407
+ raise EmptyStyle, <<~ERR.squish
408
+ Custom style for `#{self.class}` from style method cannot be empty!
409
+ ERR
410
+ end
411
+
412
+ unless ALLOWED_STYLE_TYPES.include? type
413
+ raise UnknownStyleType, <<~ERR.squish
414
+ Unknown style type for `#{self.class}` from style method!
415
+ Check return value of param type in `style :[type] do`
416
+ ERR
417
+ end
418
+
419
+ # first render the content with ERB
420
+ content = render_string(content, :erb)
421
+
422
+ render_string(content, type)
423
+ end
424
+
425
+ # Method returning style from file (style.(css|sass|scss|less)) if exists.
426
+ #
427
+ # @return [String]
428
+ def render_style_from_file
429
+ style_path = self.class.style_path
430
+ return '' unless style_path
431
+
432
+ content = ::File.read(style_path)
433
+ type = self.class.style_type
434
+
435
+ return content if type == :css
436
+
437
+ content = render_string(content, :erb)
438
+ render_string(content, type)
439
+ end
440
+
441
+ # Method returning style from method in class file.
442
+ # Usage:
443
+ #
444
+ # style do
445
+ # '.my-class { color: red; }'
446
+ # end
447
+ #
448
+ # or:
449
+ #
450
+ # style :sass do
451
+ # <<~SASS
452
+ # .my-class
453
+ # color: red
454
+ # SASS
455
+ # end
456
+ #
457
+ # @return [String]
458
+ def render_class_method_style
459
+ render_custom_style(self.class.method_style)
460
+ end
461
+
462
+ # Method returning style from params in view.
463
+ # Usage:
464
+ #
465
+ # <%= ExampleComponent data: data, style: '.my-class { color: red; }' %>
466
+ #
467
+ # or:
468
+ #
469
+ # <%= ExampleComponent data: data, style: {style: '.my-class { color: red; }', type: 'sass'} %>
470
+ #
471
+ # @return [String]
472
+ def render_style_from_inline
473
+ render_custom_style(@style)
474
+ end
475
+
476
+ # @param content [String]
477
+ # @param type [Symbol]
478
+ # @param block [Proc, nil]
479
+ # @return [String]
480
+ def render_string(content, type, block = nil)
481
+ ::Tilt[type].new { content }.render(self, &block)
482
+ end
483
+
484
+ # @return [String]
485
+ def inject_styles
486
+ style_content = render_style_from_file + render_class_method_style + render_style_from_inline
487
+ return if style_content.empty?
488
+
489
+ ::AmberComponent::StyleInjector.inject(style_content)
490
+ end
491
+ end
492
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ::AmberComponent
4
+ # Contains methods for quickly rendering
5
+ # components defined under the root namespace `Object`.
6
+ module Helper
7
+ end
8
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+
5
+ # Helper class for injecting styles into DOM.
6
+ class ::AmberComponent::StyleInjector
7
+ class << self
8
+
9
+ # Injects styles into the DOM, or returns string if
10
+ # DOM stucture is not available.
11
+ #
12
+ # @param style [String]
13
+ # @return [String, nil]
14
+ def inject(style)
15
+ injector = new(style)
16
+ injector.run
17
+ end
18
+ end
19
+
20
+ # @param style [String]
21
+ def initialize(style)
22
+ @style = style
23
+ end
24
+
25
+ # @return [void]
26
+ def run
27
+ return dom_tag unless dom_available?
28
+
29
+ insert_style_in_head
30
+ nil
31
+ end
32
+
33
+ private
34
+
35
+ # @return [Boolean]
36
+ def dom_available?
37
+ false
38
+ # check for header in DOM rendrer
39
+ end
40
+
41
+ # @return [void]
42
+ def insert_style_in_head
43
+ false
44
+ end
45
+
46
+ # @return [String]
47
+ def dom_tag
48
+ <<~HTML
49
+ <style type='text/css'>#{@style}</style>
50
+ HTML
51
+ end
52
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ::AmberComponent
4
+ # Contains the content and type of an asset.
5
+ class TypedContent
6
+ class << self
7
+ # @param val [Hash, self]
8
+ # @return [self]
9
+ def wrap(val)
10
+ return val if val.is_a?(self)
11
+
12
+ unless val.respond_to?(:[])
13
+ raise InvalidType, "`TypedContent` should be a `Hash` or `#{self}` but was `#{val.class}` (#{val.inspect})"
14
+ end
15
+
16
+ new(type: val[:type], content: val[:content])
17
+ end
18
+
19
+ alias [] wrap
20
+ end
21
+
22
+ # @param type [Symbol, String, nil]
23
+ # @param content [String, Proc, nil]
24
+ def initialize(type:, content:)
25
+ @type = type&.to_sym
26
+ @content = content
27
+ freeze
28
+ end
29
+
30
+ # @return [Symbol, nil]
31
+ attr_reader :type
32
+ # @return [String, Proc, nil]
33
+ attr_reader :content
34
+
35
+ # Stringified content.
36
+ #
37
+ # @return [String]
38
+ def to_s
39
+ return @content.call.to_s if @content.is_a?(::Proc)
40
+
41
+ @content.to_s
42
+ end
43
+
44
+ alias string_content to_s
45
+ end
46
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ::AmberComponent
4
+ VERSION = '0.0.2'
5
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails'
4
+ require 'active_support'
5
+ require 'active_support/core_ext'
6
+
7
+ module ::AmberComponent
8
+ class Error < ::StandardError; end
9
+ class ViewFileNotFound < Error; end
10
+ class InvalidType < Error; end
11
+
12
+ class EmptyView < Error; end
13
+ class UnknownViewType < Error; end
14
+ class MultipleViews < Error; end
15
+
16
+ class EmptyStyle < Error; end
17
+ class UnknownStyleType < Error; end
18
+ class MultipleStyles < Error; end
19
+ end
20
+
21
+ require_relative 'amber_component/version'
22
+ require_relative 'amber_component/helper'
23
+ require_relative 'amber_component/typed_content'
24
+ require_relative 'amber_component/base'
@@ -0,0 +1,4 @@
1
+ module AmberComponent
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end