ruby-exclaim 0.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|