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.
- checksums.yaml +7 -0
- data/.circleci/config.yml +43 -0
- data/.github/workflows/release.yml +29 -0
- data/.gitignore +12 -0
- data/.overcommit.yml +13 -0
- data/.rspec +3 -0
- data/.rubocop.yml +12 -0
- data/CHANGELOG.md +14 -0
- data/Gemfile +11 -0
- data/LICENSE.txt +22 -0
- data/README.md +806 -0
- data/Rakefile +10 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/lib/exclaim.rb +32 -0
- data/lib/exclaim/bind.rb +28 -0
- data/lib/exclaim/component.rb +7 -0
- data/lib/exclaim/errors.rb +23 -0
- data/lib/exclaim/helper.rb +7 -0
- data/lib/exclaim/implementable.rb +14 -0
- data/lib/exclaim/implementation_map.rb +93 -0
- data/lib/exclaim/implementations/each_component.rb +32 -0
- data/lib/exclaim/implementations/example_implementation_map.rb +31 -0
- data/lib/exclaim/implementations/if_helper.rb +28 -0
- data/lib/exclaim/implementations/image_component.rb +12 -0
- data/lib/exclaim/implementations/join_helper.rb +12 -0
- data/lib/exclaim/implementations/let_component.rb +16 -0
- data/lib/exclaim/implementations/paragraph_component.rb +15 -0
- data/lib/exclaim/implementations/text_component.rb +8 -0
- data/lib/exclaim/implementations/vbox_component.rb +22 -0
- data/lib/exclaim/railtie.rb +11 -0
- data/lib/exclaim/renderer.rb +65 -0
- data/lib/exclaim/ui.rb +127 -0
- data/lib/exclaim/ui_configuration.rb +139 -0
- data/lib/exclaim/utilities.rb +20 -0
- data/lib/exclaim/version.rb +5 -0
- data/ruby-exclaim.gemspec +42 -0
- metadata +166 -0
data/Rakefile
ADDED
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
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
|
data/lib/exclaim/bind.rb
ADDED
@@ -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,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,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,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
|