amber_component 0.0.2

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.
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