ruby-exclaim 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+
5
+
6
+ require 'rspec/core/rake_task'
7
+
8
+ RSpec::Core::RakeTask.new(:spec)
9
+
10
+ task default: :spec
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'exclaim'
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require 'irb'
15
+ IRB.start
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -v
5
+
6
+ bundle update
7
+
8
+ overcommit --install
data/lib/exclaim.rb ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'exclaim/version'
5
+ require 'exclaim/errors'
6
+ require 'exclaim/utilities'
7
+ require 'exclaim/implementation_map'
8
+ require 'exclaim/implementations/example_implementation_map'
9
+ require 'exclaim/implementable'
10
+ require 'exclaim/component'
11
+ require 'exclaim/helper'
12
+ require 'exclaim/bind'
13
+ require 'exclaim/ui_configuration'
14
+ require 'exclaim/renderer'
15
+ require 'exclaim/ui'
16
+ require 'exclaim/railtie' if defined?(Rails)
17
+
18
+ module Exclaim
19
+ extend Utilities
20
+ extend self
21
+
22
+ class << self
23
+ attr_accessor :logger
24
+
25
+ def configure
26
+ yield self
27
+ end
28
+ end
29
+
30
+ # defaults
31
+ self.logger = Logger.new($stdout)
32
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exclaim
4
+ class Bind
5
+ attr_reader :path, :json_declaration
6
+
7
+ def initialize(path:, json_declaration: nil)
8
+ raise UiConfigurationError.new("$bind path must be a String, found #{path.class}") unless path.is_a?(String)
9
+
10
+ @json_declaration = json_declaration
11
+ self.path = path
12
+ end
13
+
14
+ def path=(value)
15
+ @path = value
16
+ @path_keys = @path.split('.')
17
+ @path_keys_for_arrays = @path_keys.map do |string|
18
+ Integer(string)
19
+ rescue ArgumentError, TypeError
20
+ string
21
+ end
22
+ end
23
+
24
+ def evaluate(env)
25
+ env.dig(*@path_keys_for_arrays) || env.dig(*@path_keys)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exclaim
4
+ class Component
5
+ include Implementable
6
+ end
7
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exclaim
4
+ # allow callers to rescue Exclaim::Error
5
+ module Error
6
+ end
7
+
8
+ module InternalError
9
+ include Error
10
+ end
11
+
12
+ class ImplementationMapError < RuntimeError
13
+ include Error
14
+ end
15
+
16
+ class UiConfigurationError < RuntimeError
17
+ include Error
18
+ end
19
+
20
+ class RenderingError < RuntimeError
21
+ include Error
22
+ end
23
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exclaim
4
+ class Helper
5
+ include Implementable
6
+ end
7
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exclaim
4
+ module Implementable
5
+ attr_accessor :json_declaration, :name, :implementation, :config
6
+
7
+ def initialize(json_declaration: nil, name: nil, implementation: ->(_config, _env) { nil }, config: {})
8
+ @json_declaration = json_declaration
9
+ @name = name
10
+ @implementation = implementation
11
+ @config = config
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exclaim
4
+ module ImplementationMap
5
+ extend self
6
+
7
+ def parse!(implementation_map)
8
+ unless implementation_map.is_a?(Hash)
9
+ raise ImplementationMapError.new("implementation_map must be a Hash, given: #{implementation_map.class}")
10
+ end
11
+
12
+ implementation_map.each do |name, implementation|
13
+ validate_name!(name)
14
+ validate_call_params!(name, implementation)
15
+ validate_predicate_methods!(name, implementation)
16
+ end
17
+
18
+ implementation_map
19
+ end
20
+
21
+ private
22
+
23
+ def validate_name!(name)
24
+ case name
25
+ when String
26
+ if name == ''
27
+ raise ImplementationMapError.new('implementation name cannot be the empty String')
28
+ elsif name.start_with?('$')
29
+ raise ImplementationMapError.new("implementation key '#{name}' must not start with the $ symbol, " \
30
+ 'use the un-prefixed name')
31
+ end
32
+ else
33
+ raise ImplementationMapError.new("implementation name must be a String, found: #{name.inspect}")
34
+ end
35
+ end
36
+
37
+ def validate_call_params!(name, implementation)
38
+ unless implementation.respond_to?(:call)
39
+ raise ImplementationMapError.new("implementation for '#{name}' does not respond to call")
40
+ end
41
+
42
+ call_params = if implementation.respond_to?(:parameters)
43
+ implementation.parameters
44
+ else
45
+ implementation.method(:call).parameters
46
+ end
47
+ error_message = "implementation for '#{name}' must accept two positional parameters for config and env, " \
48
+ "and optionally a render_child block. Actual parameters: #{call_params}"
49
+ raise ImplementationMapError.new(error_message) unless call_params_valid?(call_params)
50
+ end
51
+
52
+ def call_params_valid?(call_params)
53
+ return false unless call_params.count >= 2 && call_params.count <= 3
54
+
55
+ call_params.each_with_index do |param, idx|
56
+ if idx < 2
57
+ return false unless positional_param_valid?(param)
58
+ else
59
+ return false unless param[0] == :block
60
+ end
61
+ end
62
+ end
63
+
64
+ def positional_param_valid?(param)
65
+ case param
66
+ in [:opt, _]
67
+ true
68
+ in [:req, _]
69
+ true
70
+ else
71
+ false
72
+ end
73
+ end
74
+
75
+ def validate_predicate_methods!(name, implementation)
76
+ unless implementation.respond_to?(:component?) || implementation.respond_to?(:helper?)
77
+ raise ImplementationMapError.new("implementation for '#{name}' must provide a " \
78
+ 'component? or helper? predicate method')
79
+ end
80
+
81
+ # if implementation defines both predicates, verify they return opposite values
82
+ # otherwise, implementation only defines one of the predicates, so we can assume the opposite for the other
83
+ if implementation.respond_to?(:component?) && implementation.respond_to?(:helper?)
84
+ # use ! to coerce to booleans
85
+ same = !implementation.component? == !implementation.helper?
86
+ if same
87
+ raise ImplementationMapError.new("implementation for '#{name}' must provide opposite truth values " \
88
+ 'for component? and helper? methods')
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exclaim
4
+ module Implementations
5
+ class Each
6
+ def component?
7
+ true
8
+ end
9
+
10
+ def call(config, env)
11
+ items = (config['items'] || config['$each']).to_a
12
+ bind_reference = config['yield']
13
+
14
+ # This implementation mutates the env Hash before passing it to each child element,
15
+ # and then restores the env to its original state at the end.
16
+ original_env_includes_bind_reference = env.key?(bind_reference)
17
+ original_bind_value = env[bind_reference] if original_env_includes_bind_reference
18
+ resolved_items = items.map do |item|
19
+ env[bind_reference] = item
20
+ yield config['do'], env # yields to render_child block
21
+ end
22
+
23
+ resolved_items.map { |line| line.end_with?("\n") ? line : "#{line}\n" }.join
24
+ ensure
25
+ env.delete(bind_reference)
26
+ env[bind_reference] = original_bind_value if original_env_includes_bind_reference
27
+ end
28
+ end
29
+
30
+ EACH_COMPONENT = Each.new
31
+ end
32
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'each_component'
4
+ require_relative 'image_component'
5
+ require_relative 'if_helper'
6
+ require_relative 'join_helper'
7
+ require_relative 'let_component'
8
+ require_relative 'paragraph_component'
9
+ require_relative 'text_component'
10
+ require_relative 'vbox_component'
11
+
12
+ module Exclaim
13
+ module Implementations
14
+ extend self
15
+
16
+ def example_implementation_map
17
+ @example_implementation_map ||= begin
18
+ {
19
+ 'each' => EACH_COMPONENT,
20
+ 'image' => IMAGE_COMPONENT,
21
+ 'if' => IF_HELPER,
22
+ 'join' => JOIN_HELPER,
23
+ 'let' => LET_COMPONENT,
24
+ 'paragraph' => PARAGRAPH_COMPONENT,
25
+ 'text' => TEXT_COMPONENT,
26
+ 'vbox' => VBOX_COMPONENT
27
+ }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exclaim
4
+ module Implementations
5
+ IF_HELPER = ->(config, _env) do
6
+ # 'condition' is the shorthand property for this helper,
7
+ # but "config['condition']" will return the falsy value nil if that key
8
+ # does not exist in the config. That will happen when the condition is configured
9
+ # with the shorthand property "{ '$if' => <some value> }"
10
+ #
11
+ # Therefore it is necessary to check for the 'condition' key to find configuration like
12
+ # "{ 'condition' => false }" or an even explicit "{ 'condition' => nil }"
13
+ # Then we fall back to the shorthand property if the 'condition' key does not exist.
14
+ condition = if config.key?('condition')
15
+ config['condition']
16
+ else
17
+ config['$if']
18
+ end
19
+
20
+ if condition
21
+ config['then'] unless config['then'].nil?
22
+ else
23
+ config['else']
24
+ end
25
+ end
26
+ IF_HELPER.define_singleton_method(:helper?) { true }
27
+ end
28
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exclaim
4
+ module Implementations
5
+ IMAGE_COMPONENT = ->(config, _env) do
6
+ source = config['source'] || config['$image']
7
+ alt = config['alt']
8
+ "<img src=\"#{source}\" alt=\"#{alt}\">"
9
+ end
10
+ IMAGE_COMPONENT.define_singleton_method(:component?) { true }
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exclaim
4
+ module Implementations
5
+ JOIN_HELPER = ->(config, _env) do
6
+ items = (config['items'] || config['$join']).to_a
7
+ separator = config['separator'] || ''
8
+ items.join(separator)
9
+ end
10
+ JOIN_HELPER.define_singleton_method(:helper?) { true }
11
+ end
12
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exclaim
4
+ module Implementations
5
+ LET_COMPONENT = ->(config, _env, &render_child) do
6
+ bindings = (config['bindings'] || config['$let']).to_h
7
+ child_component = config['do']
8
+
9
+ # This implementation passes only the configured bindings as the env for
10
+ # the child component. As an alternative approach, it could merge the bindings
11
+ # onto the parent env to make all values available to the child.
12
+ render_child.call(child_component, bindings)
13
+ end
14
+ LET_COMPONENT.define_singleton_method(:component?) { true }
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exclaim
4
+ module Implementations
5
+ PARAGRAPH_COMPONENT = ->(config, env, &render_child) do
6
+ sentences = config['sentences'] || config['$paragraph']
7
+ rendered_sentences = sentences.map do |sentence|
8
+ result = render_child.call(sentence, env)
9
+ result.end_with?('.') ? result : "#{result}."
10
+ end
11
+ "<p>#{rendered_sentences.join(' ')}</p>"
12
+ end
13
+ PARAGRAPH_COMPONENT.define_singleton_method(:component?) { true }
14
+ end
15
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exclaim
4
+ module Implementations
5
+ TEXT_COMPONENT = ->(config, _env) { config['content'] || config['$text'] }
6
+ TEXT_COMPONENT.define_singleton_method(:component?) { true }
7
+ end
8
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exclaim
4
+ module Implementations
5
+ INDENT = ' '
6
+
7
+ VBOX_COMPONENT = ->(config, env, &render_child) do
8
+ first_line = '<div style="display: flex; flex-flow: column">'
9
+ child_elements = (config['children'] || config['$vbox']).to_a
10
+ child_lines = child_elements.flat_map do |child|
11
+ result = render_child.call(child, env)
12
+ result.lines.map { |line| "#{INDENT}#{line}" }
13
+ end
14
+ last_line = '</div>'
15
+
16
+ # ensure each line ends with at least one newline to produce readable HTML
17
+ lines = [first_line, *child_lines, last_line]
18
+ lines.map { |line| line.end_with?("\n") ? line : "#{line}\n" }.join
19
+ end
20
+ VBOX_COMPONENT.define_singleton_method(:component?) { true }
21
+ end
22
+ end