ruby-exclaim 0.0.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.
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exclaim
4
+ class Railtie < Rails::Railtie
5
+ initializer 'exclaim.config' do
6
+ Exclaim.configure do |config|
7
+ config.logger = Rails.logger
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exclaim
4
+ class Renderer
5
+ def initialize(parsed_ui)
6
+ @parsed_ui = parsed_ui
7
+ end
8
+
9
+ def call(env: {})
10
+ top_level_component = @parsed_ui
11
+ render_element(top_level_component, env)
12
+ end
13
+
14
+ private
15
+
16
+ def render_element(element, env)
17
+ case element
18
+ in Component => component
19
+ resolved_config = resolve_component_config(component, env)
20
+ render_child = method(:render_element)
21
+ component.implementation.call(resolved_config, env, &render_child)
22
+ else
23
+ resolve(element, env)
24
+ end
25
+ end
26
+
27
+ def resolve_component_config(component, env)
28
+ resolve(component.config, env).transform_values! { |value| escape_html!(value) }
29
+ end
30
+
31
+ def escape_html!(value)
32
+ case value
33
+ when String
34
+ CGI.escape_html(value)
35
+ when Hash
36
+ value.transform_values! { |v| escape_html!(v) }
37
+ when Array
38
+ value.map! { |v| escape_html!(v) }
39
+ when Numeric, TrueClass, FalseClass, NilClass
40
+ value
41
+ else
42
+ # assumed to be a custom wrapper class returned by a helper
43
+ value
44
+ end
45
+ end
46
+
47
+ def resolve(element, env)
48
+ case element
49
+ in Component => component
50
+ component # will be resolved by calling its implementation later
51
+ in Bind => bind
52
+ bind.evaluate(env)
53
+ in Helper => helper
54
+ resolved_helper_config = resolve(helper.config, env)
55
+ helper.implementation.call(resolved_helper_config, env)
56
+ in Hash => hash
57
+ hash.transform_values { |value| resolve(value, env) }
58
+ in Array => array
59
+ array.map { |item| resolve(item, env) }
60
+ else
61
+ element
62
+ end
63
+ end
64
+ end
65
+ end
data/lib/exclaim/ui.rb ADDED
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exclaim
4
+ class Ui
5
+ attr_reader :implementation_map, :parsed_ui, :renderer
6
+
7
+ def initialize(implementation_map: Exclaim::Implementations.example_implementation_map)
8
+ @implementation_map = Exclaim::ImplementationMap.parse!(implementation_map)
9
+ rescue Exclaim::Error
10
+ raise
11
+ rescue StandardError => e
12
+ e.extend(Exclaim::InternalError)
13
+ raise
14
+ end
15
+
16
+ def parse_ui!(ui_config)
17
+ self.parsed_ui = Exclaim::UiConfiguration.parse!(@implementation_map, ui_config)
18
+ rescue Exclaim::Error
19
+ raise
20
+ rescue StandardError => e
21
+ e.extend(Exclaim::InternalError)
22
+ raise
23
+ end
24
+
25
+ def render(env: {})
26
+ if parsed_ui.nil?
27
+ error_message = 'Cannot render without UI configured, must call Exclaim::Ui#parse_ui(ui_config) first'
28
+ raise RenderingError.new(error_message)
29
+ end
30
+
31
+ renderer.call(env: env)
32
+ rescue Exclaim::Error
33
+ raise
34
+ rescue StandardError => e
35
+ e.extend(Exclaim::InternalError)
36
+ raise
37
+ end
38
+
39
+ def unique_bind_paths
40
+ if parsed_ui.nil?
41
+ error_message = 'Cannot compute unique_bind_paths without UI configured, ' \
42
+ 'must call Exclaim::Ui#parse_ui(ui_config) first'
43
+ raise UiConfigurationError.new(error_message)
44
+ end
45
+
46
+ parsed_ui.config.reduce([]) { |all_paths, config_value| bind_paths(config_value, all_paths) }.uniq!
47
+ end
48
+
49
+ def each_element(element_names = :ALL_ELEMENTS, &blk)
50
+ if parsed_ui.nil?
51
+ error_message = 'Cannot compute each_element without UI configured, ' \
52
+ 'must call Exclaim::Ui#parse_ui(ui_config) first'
53
+ raise UiConfigurationError.new(error_message)
54
+ end
55
+ normalized_element_names = parse_element_names(element_names)
56
+
57
+ if block_given?
58
+ top_level_component = parsed_ui
59
+ recurse_json_declarations(top_level_component, normalized_element_names, &blk)
60
+ else
61
+ to_enum(__callee__)
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def parsed_ui=(value)
68
+ @parsed_ui = value
69
+ @renderer = Exclaim::Renderer.new(@parsed_ui)
70
+ end
71
+
72
+ def bind_paths(config_value, accumulator)
73
+ case config_value
74
+ in Hash => hash
75
+ hash.values.each { |val| bind_paths(val, accumulator) }
76
+ in Array => array
77
+ array.each { |val| bind_paths(val, accumulator) }
78
+ in Bind => bind
79
+ accumulator.push(bind.path)
80
+ in Helper | Component => element
81
+ bind_paths(element.config, accumulator)
82
+ else
83
+ nil
84
+ end
85
+
86
+ accumulator
87
+ end
88
+
89
+ def parse_element_names(element_names)
90
+ case element_names
91
+ when :ALL_ELEMENTS
92
+ :ALL_ELEMENTS
93
+ when String
94
+ [normalize_name(element_names)]
95
+ when Array
96
+ element_names.map { |en| normalize_name(en) }
97
+ else
98
+ raise UiConfigurationError.new('Exclaim::Ui#each_element: element_names argument ' \
99
+ "must be a String or Array, given #{element_names.class}")
100
+ end
101
+ end
102
+
103
+ def normalize_name(element_name)
104
+ element_name.start_with?('$') ? element_name[1..] : element_name
105
+ end
106
+
107
+ def recurse_json_declarations(config_value, element_names, &blk)
108
+ case config_value
109
+ in Bind => bind
110
+ yield bind.json_declaration if element_matches?(element_names, 'bind')
111
+ in Component | Helper => element
112
+ yield element.json_declaration if element_matches?(element_names, element.name)
113
+ element.config.each_value { |val| recurse_json_declarations(val, element_names, &blk) }
114
+ in Array => array
115
+ array.each { |val| recurse_json_declarations(val, element_names, &blk) }
116
+ in Hash => hash
117
+ hash.each_value { |val| recurse_json_declarations(val, element_names, &blk) }
118
+ else
119
+ nil
120
+ end
121
+ end
122
+
123
+ def element_matches?(requested_element_names, parsed_element_name)
124
+ requested_element_names == :ALL_ELEMENTS || requested_element_names.include?(parsed_element_name)
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exclaim
4
+ module UiConfiguration
5
+ extend self
6
+
7
+ EXPLICIT_ELEMENT_NAMES = ['$component', '$helper', '$bind'].freeze
8
+
9
+ def parse!(implementation_map, ui_config)
10
+ raise UiConfigurationError.new("ui_config must be a Hash, given: #{ui_config.class}") unless ui_config.is_a?(Hash)
11
+
12
+ parsed_ui = parse_config_value(implementation_map, ui_config)
13
+
14
+ unless parsed_ui.is_a?(Exclaim::Component)
15
+ error_message = 'ui_config must declare a component at the top-level that is present in implementation_map'
16
+ raise UiConfigurationError.new(error_message)
17
+ end
18
+
19
+ parsed_ui
20
+ end
21
+
22
+ private
23
+
24
+ def parse_config_value(implementation_map, config_value)
25
+ case config_value
26
+ in Hash => hash
27
+ parse_config_hash(implementation_map, hash)
28
+ in Array => array
29
+ array.map { |value| parse_config_value(implementation_map, value) }
30
+ else
31
+ config_value
32
+ end
33
+ end
34
+
35
+ def parse_config_hash(implementation_map, config_hash)
36
+ # config_hash will be either an Exclaim element declaration or just plain configuration values
37
+ element_name = parse_element_name(implementation_map, config_hash)
38
+ if element_name.nil?
39
+ config_hash.transform_values { |val| parse_config_value(implementation_map, val) }
40
+ else
41
+ parse_element(implementation_map, element_name, config_hash)
42
+ end
43
+ end
44
+
45
+ def parse_element(implementation_map, element_name, element_declaration_hash)
46
+ implementation = implementation_map[element_name]
47
+
48
+ if helper?(implementation)
49
+ config = parse_element_config(implementation_map, element_declaration_hash)
50
+ Exclaim::Helper.new(json_declaration: element_declaration_hash,
51
+ name: element_name,
52
+ implementation: implementation,
53
+ config: config)
54
+ elsif component?(implementation)
55
+ config = parse_element_config(implementation_map, element_declaration_hash)
56
+ Exclaim::Component.new(json_declaration: element_declaration_hash,
57
+ name: element_name,
58
+ implementation: implementation,
59
+ config: config)
60
+ else
61
+ Exclaim::Bind.new(json_declaration: element_declaration_hash, path: element_declaration_hash['$bind'])
62
+ end
63
+ end
64
+
65
+ def parse_element_config(implementation_map, element_declaration_hash)
66
+ element_declaration_hash.each_with_object({}) do |(key, val), parsed_element_config|
67
+ parsed_element_config[key] = if ['$component', '$helper'].include?(key)
68
+ val
69
+ else
70
+ parse_config_value(implementation_map, val)
71
+ end
72
+ end
73
+ end
74
+
75
+ def parse_element_name(implementation_map, config_hash)
76
+ candidate_names = config_hash.keys.filter_map do |key|
77
+ key[1..] if key.start_with?('$') && !EXPLICIT_ELEMENT_NAMES.include?(key)
78
+ end
79
+ candidate_names.reject! do |name|
80
+ unrecognized = implementation_map[name].nil?
81
+ Exclaim.logger.warn("ui_config includes key \"$#{name}\" which has no matching implementation") if unrecognized
82
+ unrecognized
83
+ end
84
+
85
+ explicit_component_name = parse_explicit_declaration(implementation_map, config_hash, '$component')
86
+ explicit_helper_name = parse_explicit_declaration(implementation_map, config_hash, '$helper')
87
+
88
+ candidate_names.push(explicit_component_name, explicit_helper_name)
89
+ candidate_names.compact!
90
+
91
+ # binds do not have implementations, but they are still special Exclaim elements
92
+ candidate_names.push('bind') if config_hash.include?('$bind')
93
+
94
+ if candidate_names.count > 1
95
+ error_message = "Multiple Exclaim elements defined at one configuration level: #{candidate_names}. " \
96
+ 'Only one allowed.'
97
+ raise UiConfigurationError.new(error_message)
98
+ end
99
+
100
+ # returns nil when config_hash is not an Exclaim element declaration
101
+ candidate_names.first
102
+ end
103
+
104
+ def parse_explicit_declaration(implementation_map, config_hash, explicit_key)
105
+ explicit_name = config_hash[explicit_key]
106
+ return nil if explicit_name.nil?
107
+
108
+ if explicit_name.start_with?('$')
109
+ error_message = "Invalid: \"#{explicit_key}\": \"#{explicit_name}\", " \
110
+ "when declaring explicit \"#{explicit_key}\" do not prefix the name with \"$\""
111
+ raise UiConfigurationError.new(error_message)
112
+ end
113
+
114
+ if implementation_map[explicit_name].nil?
115
+ error_message = "ui_config declares \"#{explicit_key}\": \"#{explicit_name}\" " \
116
+ 'which has no matching implementation'
117
+ raise UiConfigurationError.new(error_message)
118
+ end
119
+
120
+ explicit_name
121
+ end
122
+
123
+ def component?(element)
124
+ element_type(element) == 'component'
125
+ end
126
+
127
+ def helper?(element)
128
+ element_type(element) == 'helper'
129
+ end
130
+
131
+ def element_type(element)
132
+ if element.respond_to?(:component?)
133
+ element.component? ? 'component' : 'helper'
134
+ elsif element.respond_to?(:helper?)
135
+ element.helper? ? 'helper' : 'component'
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exclaim
4
+ module Utilities
5
+ def element_name(config_hash)
6
+ unless config_hash.is_a?(Hash)
7
+ error_message = "Exclaim.element_name can only determine name from a Hash, given #{config_hash.class} value"
8
+ Exclaim.logger.warn(error_message)
9
+ return
10
+ end
11
+
12
+ return config_hash['$component'] if config_hash.include?('$component')
13
+ return config_hash['$helper'] if config_hash.include?('$helper')
14
+ return 'bind' if config_hash.include?('$bind')
15
+
16
+ shorthand_name = config_hash.keys.find { |key| key.start_with?('$') }
17
+ shorthand_name&.[](1..)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exclaim
4
+ VERSION = '0.0.0'
5
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'exclaim/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'ruby-exclaim'
9
+ spec.version = Exclaim::VERSION
10
+ spec.authors = ['Salsify, Inc']
11
+ spec.email = ['engineering@salsify.com']
12
+
13
+ spec.summary = 'Ruby utilities for Exclaim UIs'
14
+ spec.description = spec.summary
15
+ spec.homepage = 'https://github.com/salsify/ruby-exclaim'
16
+
17
+ spec.license = 'MIT'
18
+
19
+
20
+ # Set 'allowed_push_post' to control where this gem can be published.
21
+ if spec.respond_to?(:metadata)
22
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
23
+
24
+ else
25
+ raise 'RubyGems 2.0 or newer is required to protect against public gem pushes.'
26
+ end
27
+
28
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
29
+ spec.bindir = 'bin'
30
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
31
+ spec.require_paths = ['lib']
32
+
33
+ spec.required_ruby_version = '>= 2.7'
34
+
35
+ spec.add_development_dependency 'bundler', '~> 2.0'
36
+ spec.add_development_dependency 'overcommit'
37
+ spec.add_development_dependency 'rake', '~> 13.0'
38
+ spec.add_development_dependency 'rspec', '~> 3.9'
39
+ spec.add_development_dependency 'rspec_junit_formatter', '~> 0.4.1'
40
+
41
+ spec.add_development_dependency 'salsify_rubocop', '~> 1.0'
42
+ end
metadata ADDED
@@ -0,0 +1,166 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby-exclaim
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Salsify, Inc
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-03-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: overcommit
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.9'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.9'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec_junit_formatter
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 0.4.1
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 0.4.1
83
+ - !ruby/object:Gem::Dependency
84
+ name: salsify_rubocop
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.0'
97
+ description: Ruby utilities for Exclaim UIs
98
+ email:
99
+ - engineering@salsify.com
100
+ executables:
101
+ - console
102
+ - setup
103
+ extensions: []
104
+ extra_rdoc_files: []
105
+ files:
106
+ - ".circleci/config.yml"
107
+ - ".github/workflows/release.yml"
108
+ - ".gitignore"
109
+ - ".overcommit.yml"
110
+ - ".rspec"
111
+ - ".rubocop.yml"
112
+ - CHANGELOG.md
113
+ - Gemfile
114
+ - LICENSE.txt
115
+ - README.md
116
+ - Rakefile
117
+ - bin/console
118
+ - bin/setup
119
+ - lib/exclaim.rb
120
+ - lib/exclaim/bind.rb
121
+ - lib/exclaim/component.rb
122
+ - lib/exclaim/errors.rb
123
+ - lib/exclaim/helper.rb
124
+ - lib/exclaim/implementable.rb
125
+ - lib/exclaim/implementation_map.rb
126
+ - lib/exclaim/implementations/each_component.rb
127
+ - lib/exclaim/implementations/example_implementation_map.rb
128
+ - lib/exclaim/implementations/if_helper.rb
129
+ - lib/exclaim/implementations/image_component.rb
130
+ - lib/exclaim/implementations/join_helper.rb
131
+ - lib/exclaim/implementations/let_component.rb
132
+ - lib/exclaim/implementations/paragraph_component.rb
133
+ - lib/exclaim/implementations/text_component.rb
134
+ - lib/exclaim/implementations/vbox_component.rb
135
+ - lib/exclaim/railtie.rb
136
+ - lib/exclaim/renderer.rb
137
+ - lib/exclaim/ui.rb
138
+ - lib/exclaim/ui_configuration.rb
139
+ - lib/exclaim/utilities.rb
140
+ - lib/exclaim/version.rb
141
+ - ruby-exclaim.gemspec
142
+ homepage: https://github.com/salsify/ruby-exclaim
143
+ licenses:
144
+ - MIT
145
+ metadata:
146
+ allowed_push_host: https://rubygems.org
147
+ post_install_message:
148
+ rdoc_options: []
149
+ require_paths:
150
+ - lib
151
+ required_ruby_version: !ruby/object:Gem::Requirement
152
+ requirements:
153
+ - - ">="
154
+ - !ruby/object:Gem::Version
155
+ version: '2.7'
156
+ required_rubygems_version: !ruby/object:Gem::Requirement
157
+ requirements:
158
+ - - ">="
159
+ - !ruby/object:Gem::Version
160
+ version: '0'
161
+ requirements: []
162
+ rubygems_version: 3.2.11
163
+ signing_key:
164
+ specification_version: 4
165
+ summary: Ruby utilities for Exclaim UIs
166
+ test_files: []