scarpe-components 0.2.2 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest"
4
+ require "json"
5
+ #require "json/add/exception"
6
+
7
+ module Scarpe; module Components; end; end
8
+
9
+ # A MinitestResult imports a JSON file from a minitest_export_reporter.
10
+ # But instead of creating a Minitest::Test to report the result, the
11
+ # MinitestResult is just a queryable Ruby object.
12
+ #
13
+ # MinitestResult assumes there will be only one class and one method
14
+ # in the JSON, which is true for Scarpe but not necessarily in general.
15
+ class Scarpe::Components::MinitestResult
16
+ attr_reader :assertions
17
+ attr_reader :method_name
18
+ attr_reader :class_name
19
+
20
+ def initialize(filename)
21
+ data = JSON.parse File.read(filename)
22
+
23
+ unless data.size == 1
24
+ # We would want a different interface to support this in general. For now we don't
25
+ # need it to work in general.
26
+ raise "Scarpe::Components::MinitestResult only supports one class and method in results!"
27
+ end
28
+
29
+ item = data.first
30
+
31
+ @assertions = item["assertions"]
32
+ @method_name = item["name"]
33
+ @class_name = item["klass"]
34
+ @time = item["time"]
35
+ @metadata = item.key?("metadata") ? item["metadata"]: {}
36
+
37
+ @skip = false
38
+ @exceptions = []
39
+ @failures = []
40
+ item["failures"].each do |f|
41
+ # JSON.parse ignores json_class and won't create an arbitrary object. That's good
42
+ # because Minitest::UnexpectedError seems to load in a bad way, so we don't want
43
+ # it to auto-instantiate.
44
+ d = JSON.parse f[1]
45
+ msg = d["m"]
46
+ case d["json_class"]
47
+ when "Minitest::UnexpectedError"
48
+ @exceptions << msg
49
+ when "Minitest::Skip"
50
+ @skip = msg
51
+ when "Minitest::Assertion"
52
+ @failures << msg
53
+ else
54
+ raise Scarpe::InternalError, "Didn't expect type #{t.inspect} as exception type when importing Minitest tests!"
55
+ end
56
+ end
57
+ end
58
+
59
+ def error?
60
+ !@exceptions.empty?
61
+ end
62
+
63
+ def fail?
64
+ !@failures.empty?
65
+ end
66
+
67
+ def skip?
68
+ @skip ? true : false
69
+ end
70
+
71
+ def passed?
72
+ @exceptions.empty? && @failures.empty? && !@skip
73
+ end
74
+
75
+ def error_message
76
+ @exceptions[0]
77
+ end
78
+
79
+ def fail_message
80
+ @failures[0]
81
+ end
82
+
83
+ def skip_message
84
+ @skip
85
+ end
86
+ end
@@ -7,9 +7,9 @@ require "shoes/log"
7
7
 
8
8
  # Requires the logging gem
9
9
 
10
- class Scarpe; end
10
+ module Scarpe; end
11
11
  module Scarpe::Components; end
12
- class Scarpe
12
+ module Scarpe
13
13
  class Components::ModularLogImpl
14
14
  include Shoes::Log # for constants
15
15
 
@@ -32,7 +32,7 @@ class Scarpe
32
32
  when "fatal"
33
33
  :fatal
34
34
  else
35
- raise "Don't know how to treat #{data.inspect} as a logger severity!"
35
+ raise Shoes::Errors::InvalidAttributeValueError, "Don't know how to treat #{data.inspect} as a logger severity!"
36
36
  end
37
37
  end
38
38
 
@@ -45,7 +45,7 @@ class Scarpe
45
45
  when String
46
46
  Logging.appenders.file data, layout: @custom_log_layout
47
47
  else
48
- raise "Don't know how to convert #{data.inspect} to an appender!"
48
+ raise Shoes::Errors::InvalidAttributeValueError, "Don't know how to convert #{data.inspect} to an appender!"
49
49
  end
50
50
  end
51
51
 
@@ -64,7 +64,7 @@ class Scarpe
64
64
 
65
65
  logger.level = name_to_severity(level)
66
66
  else
67
- raise "Don't know how to use #{data.inspect} to specify a logger!"
67
+ raise Shoes::Errors::InvalidAttributeValueError, "Don't know how to use #{data.inspect} to specify a logger!"
68
68
  end
69
69
  end
70
70
 
@@ -3,30 +3,34 @@
3
3
  require "shoes/log"
4
4
  require "json"
5
5
 
6
- class Scarpe; end
6
+ module Scarpe; end
7
7
  module Scarpe::Components; end
8
8
  class Scarpe::Components::PrintLogImpl
9
9
  include Shoes::Log # for constants
10
10
 
11
11
  class PrintLogger
12
+ class << self
13
+ attr_accessor :silence
14
+ end
15
+
12
16
  def initialize(component_name)
13
17
  @comp_name = component_name
14
18
  end
15
19
 
16
20
  def error(msg)
17
- puts "#{@comp_name} error: #{msg}"
21
+ puts "#{@comp_name} error: #{msg}" unless PrintLogger.silence
18
22
  end
19
23
 
20
24
  def warn(msg)
21
- puts "#{@comp_name} warn: #{msg}"
25
+ puts "#{@comp_name} warn: #{msg}" unless PrintLogger.silence
22
26
  end
23
27
 
24
28
  def debug(msg)
25
- puts "#{@comp_name} debug: #{msg}"
29
+ puts "#{@comp_name} debug: #{msg}" unless PrintLogger.silence
26
30
  end
27
31
 
28
32
  def info(msg)
29
- puts "#{@comp_name} info: #{msg}"
33
+ puts "#{@comp_name} info: #{msg}" unless PrintLogger.silence
30
34
  end
31
35
  end
32
36
 
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Scarpe; end
3
+ module Scarpe; end
4
4
  module Scarpe::Components; end
5
- class Scarpe
5
+ module Scarpe
6
6
  # Scarpe::Promise is a promises library, but one with no form of built-in
7
7
  # concurrency. Instead, promise callbacks are executed synchronously.
8
8
  # Even execution is usually synchronous, but can also be handled manually
@@ -198,7 +198,7 @@ class Scarpe
198
198
  case @state
199
199
  when :fulfilled
200
200
  # Should this be a no-op instead?
201
- raise "Registering an executor on an already fulfilled promise means it will never run!"
201
+ raise Scarpe::NoOperationError, "Registering an executor on an already fulfilled promise means it will never run!"
202
202
  when :rejected
203
203
  return
204
204
  when :unscheduled
@@ -207,7 +207,7 @@ class Scarpe
207
207
  @executor = block
208
208
  call_executor
209
209
  else
210
- raise "Internal error, illegal state!"
210
+ raise Scarpe::InternalError, "Internal error, illegal state!"
211
211
  end
212
212
 
213
213
  self
@@ -222,16 +222,16 @@ class Scarpe
222
222
 
223
223
  # First, filter out illegal input
224
224
  unless PROMISE_STATES.include?(old_state)
225
- raise "Internal Promise error! Internal state was #{old_state.inspect}! Legal states: #{PROMISE_STATES.inspect}"
225
+ raise Scarpe::InternalError, "Internal Promise error! Internal state was #{old_state.inspect}! Legal states: #{PROMISE_STATES.inspect}"
226
226
  end
227
227
 
228
228
  unless PROMISE_STATES.include?(new_state)
229
- raise "Internal Promise error! Internal state was set to #{new_state.inspect}! " +
229
+ raise Scarpe::InternalError, "Internal Promise error! Internal state was set to #{new_state.inspect}! " +
230
230
  "Legal states: #{PROMISE_STATES.inspect}"
231
231
  end
232
232
 
233
233
  if new_state != :fulfilled && new_state != :rejected && !value_or_reason.nil?
234
- raise "Internal promise error! Non-completed state transitions should not specify a value or reason!"
234
+ raise Scarpe::InternalError, "Internal promise error! Non-completed state transitions should not specify a value or reason!"
235
235
  end
236
236
 
237
237
  # Here's our state-transition grid for what we're doing here.
@@ -254,11 +254,11 @@ class Scarpe
254
254
 
255
255
  # Transitioning to any *different* state after being fulfilled or rejected? Nope. Those states are final.
256
256
  if complete?
257
- raise "Internal Promise error! Trying to change state from #{old_state.inspect} to #{new_state.inspect}!"
257
+ raise Scarpe::InternalError, "Internal Promise error! Trying to change state from #{old_state.inspect} to #{new_state.inspect}!"
258
258
  end
259
259
 
260
260
  if old_state == :pending && new_state == :unscheduled
261
- raise "Can't change state from :pending to :unscheduled! Scheduling is not reversible!"
261
+ raise Shoes::Errors::InvalidAttributeValueError, "Can't change state from :pending to :unscheduled! Scheduling is not reversible!"
262
262
  end
263
263
 
264
264
  # The next three checks should all be followed by calling handlers for the newly-changed state.
@@ -341,7 +341,7 @@ class Scarpe
341
341
  @on_scheduled.each { |h| h.call(*@parents.map(&:returned_value)) }
342
342
  @on_scheduled = []
343
343
  else
344
- raise "Internal error! Trying to call handlers for #{state.inspect}!"
344
+ raise Scarpe::InternalError, "Internal error! Trying to call handlers for #{state.inspect}!"
345
345
  end
346
346
  end
347
347
 
@@ -367,7 +367,7 @@ class Scarpe
367
367
  end
368
368
 
369
369
  def call_executor
370
- raise("Internal error! Should not call_executor with no executor!") unless @executor
370
+ raise(Scarpe::InternalError, "Internal error! Should not call_executor with no executor!") unless @executor
371
371
 
372
372
  begin
373
373
  result = @executor.call(*@parents.map(&:returned_value))
@@ -389,7 +389,7 @@ class Scarpe
389
389
  # @return [Scarpe::Promise] self
390
390
  def on_fulfilled(&handler)
391
391
  unless handler
392
- raise "You must pass a block to on_fulfilled!"
392
+ raise Shoes::Errors::InvalidAttributeValueError, "You must pass a block to on_fulfilled!"
393
393
  end
394
394
 
395
395
  case @state
@@ -411,7 +411,7 @@ class Scarpe
411
411
  # @return [Scarpe::Promise] self
412
412
  def on_rejected(&handler)
413
413
  unless handler
414
- raise "You must pass a block to on_rejected!"
414
+ raise Shoes::Errors::InvalidAttributeValueError, "You must pass a block to on_rejected!"
415
415
  end
416
416
 
417
417
  case @state
@@ -434,7 +434,7 @@ class Scarpe
434
434
  # @return [Scarpe::Promise] self
435
435
  def on_scheduled(&handler)
436
436
  unless handler
437
- raise "You must pass a block to on_scheduled!"
437
+ raise Shoes::Errors::InvalidAttributeValueError, "You must pass a block to on_scheduled!"
438
438
  end
439
439
 
440
440
  # Add a pending handler or call it now
@@ -14,7 +14,7 @@ module Scarpe::Components
14
14
  # @return <void>
15
15
  def add_segment_type(type, handler)
16
16
  if segment_type_hash.key?(type)
17
- raise "Segment type #{type.inspect} already exists!"
17
+ raise Shoes::Errors::InvalidAttributeValueError, "Segment type #{type.inspect} already exists!"
18
18
  end
19
19
 
20
20
  segment_type_hash[type] = handler
@@ -22,11 +22,23 @@ module Scarpe::Components
22
22
 
23
23
  # Return an Array of segment type labels, such as "code" and "app_test".
24
24
  #
25
- # @return Array<String> the segment type labels
25
+ # @return [Array<String>] the segment type labels
26
26
  def segment_types
27
27
  segment_type_hash.keys
28
28
  end
29
29
 
30
+ # Normally a Shoes application will want to keep the default segment types,
31
+ # which allow loading a Shoes app and running a test inside. But sometimes
32
+ # the default handler will be wrong and a library will want to register
33
+ # its own "shoes" and "app_test" segment handlers, or not have any at all.
34
+ # For those applications, it makes sense to clear all segment types before
35
+ # registering its own.
36
+ #
37
+ # @return <void>
38
+ def remove_all_segment_types!
39
+ @segment_type_hash = {}
40
+ end
41
+
30
42
  # Load a .sca file with an optional YAML frontmatter prefix and
31
43
  # multiple file sections which can be treated differently.
32
44
  #
@@ -38,7 +50,7 @@ module Scarpe::Components
38
50
  # @param path [String] the file or directory to treat as a Scarpe app
39
51
  # @return [Boolean] return true if the file is loaded as a segmented Scarpe app file
40
52
  def call(path)
41
- return false unless path.end_with?(".scas")
53
+ return false unless path.end_with?(".scas") || path.end_with?(".sspec")
42
54
 
43
55
  file_load(path)
44
56
  true
@@ -55,14 +67,12 @@ module Scarpe::Components
55
67
  @after_load << block
56
68
  end
57
69
 
58
- private
59
-
60
- def gen_name(segmap)
70
+ def self.gen_name(segmap)
61
71
  ctr = (1..10_000).detect { |i| !segmap.key?("%5d" % i) }
62
72
  "%5d" % ctr
63
73
  end
64
74
 
65
- def tokenize_segments(contents)
75
+ def self.front_matter_and_segments_from_file(contents)
66
76
  require "yaml" # Only load when needed
67
77
  require "English"
68
78
 
@@ -90,12 +100,16 @@ module Scarpe::Components
90
100
  segments.each do |segment|
91
101
  if segment =~ /\A-* +(.*?)\n/
92
102
  # named segment with separator
93
- segmap[::Regexp.last_match(1)] = ::Regexp.last_match.post_match
103
+ name = ::Regexp.last_match(1)
104
+
105
+ raise("Duplicate segment name: #{name.inspect}!") if segmap.key?(name)
106
+
107
+ segmap[name] = ::Regexp.last_match.post_match
94
108
  elsif segment =~ /\A-* *\n/
95
109
  # unnamed segment with separator
96
110
  segmap[gen_name(segmap)] = ::Regexp.last_match.post_match
97
111
  else
98
- raise "Internal error when parsing segments in segmented app file! seg: #{segment.inspect}"
112
+ raise Scarpe::InternalError, "Internal error when parsing segments in segmented app file! seg: #{segment.inspect}"
99
113
  end
100
114
  end
101
115
 
@@ -105,19 +119,19 @@ module Scarpe::Components
105
119
  def file_load(path)
106
120
  contents = File.read(path)
107
121
 
108
- front_matter, segmap = tokenize_segments(contents)
122
+ front_matter, segmap = self.class.front_matter_and_segments_from_file(contents)
109
123
 
110
124
  if segmap.empty?
111
- raise "Illegal segmented Scarpe file: must have at least one code segment, not just front matter!"
125
+ raise Scarpe::FileContentError, "Illegal segmented Scarpe file: must have at least one code segment, not just front matter!"
112
126
  end
113
127
 
114
128
  if front_matter[:segments]
115
129
  if front_matter[:segments].size != segmap.size
116
- raise "Number of front matter :segments must equal number of file segments!"
130
+ raise Scarpe::FileContentError, "Number of front matter :segments must equal number of file segments!"
117
131
  end
118
132
  else
119
133
  if segmap.size > 2
120
- raise "Segmented files with more than two segments have to specify what they're for!"
134
+ raise Scarpe::FileContentError, "Segmented files with more than two segments have to specify what they're for!"
121
135
  end
122
136
 
123
137
  # Set to default of shoes code only or shoes code and app test code.
@@ -132,7 +146,7 @@ module Scarpe::Components
132
146
  tf_specs = []
133
147
  front_matter[:segments].each.with_index do |seg_type, idx|
134
148
  unless sth.key?(seg_type)
135
- raise "Unrecognized segment type #{seg_type.inspect}! No matching segment type available!"
149
+ raise Scarpe::FileContentError, "Unrecognized segment type #{seg_type.inspect}! No matching segment type available!"
136
150
  end
137
151
 
138
152
  tf_specs << ["scarpe_#{seg_type}_segment_contents", sv[idx]]
@@ -147,18 +161,23 @@ module Scarpe::Components
147
161
  # Need to call @after_load hooks while tempfiles still exist
148
162
  if @after_load && !@after_load.empty?
149
163
  @after_load.each(&:call)
164
+ @after_load = []
150
165
  end
151
166
  end
152
167
  end
153
168
 
154
169
  # The hash of segment type labels mapped to handlers which will be called.
155
- # Normal client code shouldn't ever call this.
170
+ # This could be called by a display service, a test framework or similar
171
+ # code that wants to define a non-Scarpe-standard file format.
156
172
  #
157
- # @return Hash<String, Object> the name/handler pairs
173
+ # @return [Hash<String, Object>] the name/handler pairs
158
174
  def segment_type_hash
159
175
  @segment_handlers ||= {
160
176
  "shoes" => proc { |seg_file| after_load { load seg_file } },
161
- "app_test" => proc { |seg_file| ENV["SCARPE_APP_TEST"] = seg_file },
177
+ "app_test" => proc do |seg_file|
178
+ ENV["SHOES_SPEC_TEST"] = seg_file
179
+ ENV["SHOES_MINITEST_EXPORT_FILE"] = "sspec.json"
180
+ end,
162
181
  }
163
182
  end
164
183
  end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scarpe; module Components; end; end
4
+ module Scarpe::Components::StringHelpers
5
+ # Cut down from Rails camelize
6
+ def self.camelize(string)
7
+ string = string.sub(/^[a-z\d]*/, &:capitalize)
8
+ string.gsub(/(?:_|(\/))([a-z\d]*)/) { "#{::Regexp.last_match(1)}#{::Regexp.last_match(2).capitalize}" }
9
+ end
10
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ # In Italian, tiranti are bootstraps -- the literal pull-on-a-boot kind, not a step to something better.
4
+ # Tiranti.rb builds on calzini.rb, but renders a Bootstrap-decorated version of the HTML output.
5
+ # You would ordinarily set either Calzini or Tiranti as the top-level HTML renderer, not both.
6
+ # You'll include both if you use Tiranti, because it falls back to Calzini for a lot of its rendering.
7
+
8
+ require "scarpe/components/calzini"
9
+
10
+ # The Tiranti module expects to be included by a class defining
11
+ # the following methods:
12
+ #
13
+ # * html_id - the HTML ID for the specific rendered DOM object
14
+ # * handler_js_code(event_name) - the JS handler code for this DOM object and event name
15
+ # * (optional) display_properties - the display properties for this object, unless overridden in render()
16
+ module Scarpe::Components::Tiranti
17
+ include Scarpe::Components::Calzini
18
+ extend self
19
+
20
+ # Currently we're using Bootswatch 5
21
+ BOOTSWATCH_THEMES = [
22
+ "cerulean",
23
+ "cosmo",
24
+ "cyborg",
25
+ "darkly",
26
+ "flatly",
27
+ "journal",
28
+ "litera",
29
+ "lumen",
30
+ "lux",
31
+ "materia",
32
+ "minty",
33
+ "morph",
34
+ "pulse",
35
+ "quartz",
36
+ "sandstone",
37
+ "simplex",
38
+ "sketchy",
39
+ "slate",
40
+ "solar",
41
+ "spacelab",
42
+ "superhero",
43
+ "united",
44
+ "vapor",
45
+ "yeti",
46
+ "zephyr",
47
+ ]
48
+
49
+ BOOTSWATCH_THEME = ENV["SCARPE_BOOTSTRAP_THEME"] || "sketchy"
50
+
51
+ def empty_page_element
52
+ <<~HTML
53
+ <html>
54
+ <head id='head-wvroot'>
55
+ <meta charset="utf-8">
56
+ <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
57
+ <link rel="stylesheet" href="https://bootswatch.com/5/#{BOOTSWATCH_THEME}/bootstrap.css">
58
+ <link rel="stylesheet" href="https://bootswatch.com/_vendor/bootstrap-icons/font/bootstrap-icons.min.css">
59
+ <style id='style-wvroot'>
60
+ /** Style resets **/
61
+ body {
62
+ height: 100%;
63
+ overflow: hidden;
64
+ }
65
+ </style>
66
+ </head>
67
+ <body id='body-wvroot'>
68
+ <div id='wrapper-wvroot'></div>
69
+
70
+ <script src="https://bootswatch.com/_vendor/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
71
+ </body>
72
+ </html>
73
+ HTML
74
+ end
75
+
76
+ # def render_stack
77
+ # end
78
+ # def render_flow
79
+ # end
80
+
81
+ # How do we want to handle theme-specific colours and primary/secondary buttons in Bootstrap?
82
+ # "Disabled" could be checked in properties. Is there any way we can/should use "outline" buttons?
83
+ def button_element(props)
84
+ HTML.render do |h|
85
+ h.button(id: html_id, type: "button", class: "btn btn-primary", onclick: handler_js_code("click"), style: button_style(props)) do
86
+ props["text"]
87
+ end
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def button_style(props)
94
+ styles = drawable_style(props)
95
+
96
+ styles[:"background-color"] = props["color"] if props["color"]
97
+ styles[:"padding-top"] = props["padding_top"] if props["padding_top"]
98
+ styles[:"padding-bottom"] = props["padding_bottom"] if props["padding_bottom"]
99
+ styles[:color] = props["text_color"] if props["text_color"]
100
+ styles[:width] = dimensions_length(props["width"]) if props["width"]
101
+ styles[:height] = dimensions_length(props["height"]) if props["height"]
102
+ styles[:"font-size"] = props["font_size"] if props["font_size"]
103
+
104
+ styles[:top] = dimensions_length(props["top"]) if props["top"]
105
+ styles[:left] = dimensions_length(props["left"]) if props["left"]
106
+ styles[:position] = "absolute" if props["top"] || props["left"]
107
+ styles[:"font-size"] = dimensions_length(text_size(props["size"])) if props["size"]
108
+ styles[:"font-family"] = props["font"] if props["font"]
109
+
110
+ styles
111
+ end
112
+
113
+ public
114
+
115
+ def alert_element(props)
116
+ onclick = handler_js_code(props["event_name"] || "click")
117
+
118
+ HTML.render do |h|
119
+ h.div(id: html_id, class: "modal", tabindex: -1, role: "dialog", style: alert_overlay_style(props)) do
120
+ h.div(class: "modal-dialog", role: "document") do
121
+ h.div(class: "modal-content", style: alert_modal_style) do
122
+ h.div(class: "modal-header") do
123
+ h.h5(class: "modal-title") { "Alert" }
124
+ h.button(type: "button", class: "close", data_dismiss: "modal", aria_label: "Close") do
125
+ h.span(aria_hidden: "true") { "&times;" }
126
+ end
127
+ end
128
+ h.div(class: "modal-body") do
129
+ h.p { props["text"] }
130
+ end
131
+ h.div(class: "modal-footer") do
132
+ h.button(type: "button", onclick:, class: "btn btn-primary") { "OK" }
133
+ #h.button(type: "button", class: "btn btn-secondary") { "Close" }
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+
141
+ def check_element(props)
142
+ HTML.render do |h|
143
+ h.div class: "form-check" do
144
+ h.input type: :checkbox,
145
+ id: html_id,
146
+ class: "form-check-input",
147
+ onclick: handler_js_code("click"),
148
+ value: props["text"],
149
+ checked: props["checked"],
150
+ style: drawable_style(props)
151
+ end
152
+ end
153
+ end
154
+
155
+ def progress_element(props)
156
+ HTML.render do |h|
157
+ h.div(class: "progress", style: "width: 90%") do
158
+ pct = "%.1f" % ((props["fraction"] || 0.0) * 100.0)
159
+ h.div(
160
+ class: "progress-bar progress-bar-striped progress-bar-animated",
161
+ role: "progressbar",
162
+ "aria-valuenow": pct,
163
+ "aria-valuemin": 0,
164
+ "aria-valuemax": 100,
165
+ style: "width: #{pct}%",
166
+ )
167
+ end
168
+ end
169
+ end
170
+
171
+ # para_element is a bit of a hard one, since it does not-entirely-trivial
172
+ # mapping between display objects and IDs. But we don't want Calzini
173
+ # messing with the display service or display objects.
174
+ def para_element(props, &block)
175
+ tag, opts = para_elt_and_opts(props)
176
+
177
+ HTML.render do |h|
178
+ h.send(tag, **opts, &block)
179
+ end
180
+ end
181
+
182
+ private
183
+
184
+ ELT_AND_SIZE = {
185
+ inscription: [:p, 10],
186
+ ins: [:p, 10],
187
+ para: [:p, 12],
188
+ caption: [:p, 14],
189
+ tagline: [:p, 18],
190
+ subtitle: [:h3, 26],
191
+ title: [:h2, 34],
192
+ banner: [:h1, 48],
193
+ }.freeze
194
+
195
+ def para_elt_and_opts(props)
196
+ elt, size = para_elt_and_size(props)
197
+ size = dimensions_length(size)
198
+
199
+ para_style = drawable_style(props).merge({
200
+ color: rgb_to_hex(props["stroke"]),
201
+ "font-size": para_font_size(props),
202
+ "font-family": props["font"],
203
+ }.compact)
204
+
205
+ opts = (props["html_attributes"] || {}).merge(id: html_id, style: para_style)
206
+
207
+ [elt, opts]
208
+ end
209
+
210
+ def para_elt_and_size(props)
211
+ return [:p, nil] unless props["size"]
212
+
213
+ ps = props["size"].to_s.to_sym
214
+ if ELT_AND_SIZE.key?(ps)
215
+ ELT_AND_SIZE[ps]
216
+ else
217
+ sz = props["size"].to_i
218
+ if sz > 18
219
+ [:h2, sz]
220
+ else
221
+ [:p, sz]
222
+ end
223
+ end
224
+ end
225
+ end