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