scarpe-components 0.2.2 → 0.3.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.
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scarpe::Components::Calzini
4
+ def slot_element(props, &block)
5
+ HTML.render do |h|
6
+ h.div((props["html_attributes"] || {}).merge(id: html_id, style: slot_style(props)), &block)
7
+ end
8
+ end
9
+
10
+ def flow_element(props, &block)
11
+ HTML.render do |h|
12
+ h.div((props["html_attributes"] || {}).merge(id: html_id, style: flow_style(props)), &block)
13
+ end
14
+ end
15
+
16
+ def stack_element(props, &block)
17
+ HTML.render do |h|
18
+ h.div((props["html_attributes"] || {}).merge(id: html_id, style: stack_style(props)), &block)
19
+ end
20
+ end
21
+
22
+ def documentroot_element(props, &block)
23
+ HTML.render do |h|
24
+ # DocumentRoot rendering intentionally uses flow styles.
25
+ h.div((props["html_attributes"] || {}).merge(id: html_id, style: flow_style(props)), &block)
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def slot_style(props)
32
+ styles = drawable_style(props)
33
+ styles = background_style(props, styles)
34
+ styles = border_style(props, styles)
35
+ styles = spacing_styles_for_attr("margin", props, styles)
36
+ styles = spacing_styles_for_attr("padding", props, styles)
37
+
38
+ styles[:width] = dimensions_length(props["width"]) if props["width"]
39
+ styles[:height] = dimensions_length(props["height"]) if props["height"]
40
+
41
+ styles
42
+ end
43
+
44
+ def flow_style(props)
45
+ {
46
+ display: "flex",
47
+ "flex-direction": "row",
48
+ "flex-wrap": "wrap",
49
+ "align-content": "flex-start",
50
+ "justify-content": "flex-start",
51
+ "align-items": "flex-start",
52
+ }.merge(slot_style(props))
53
+ end
54
+
55
+ def stack_style(props)
56
+ {
57
+ display: "flex",
58
+ "flex-direction": "column",
59
+ "align-content": "flex-start",
60
+ "justify-content": "flex-start",
61
+ "align-items": "flex-start",
62
+ overflow: props["scroll"] ? "auto" : nil,
63
+ }.compact.merge(slot_style(props))
64
+ end
65
+
66
+ def border_style(props, styles)
67
+ bc = props["border_color"]
68
+ return styles unless bc
69
+
70
+ opts = props["options"] || {}
71
+
72
+ border_style_hash = case bc
73
+ when Range
74
+ { "border-image": "linear-gradient(45deg, #{bc.first}, #{bc.last})" }
75
+ when Array
76
+ { "border-color": "rgba(#{bc.join(", ")})" }
77
+ else
78
+ { "border-color": bc }
79
+ end
80
+ styles.merge(
81
+ "border-style": "solid",
82
+ "border-width": "#{opts["strokewidth"] || 1}px",
83
+ "border-radius": "#{opts["curve"] || 0}px",
84
+ ).merge(border_style_hash)
85
+ end
86
+
87
+ def background_style(props, styles)
88
+ bc = props["background_color"]
89
+ return styles unless bc
90
+
91
+ color = case bc
92
+ when Array
93
+ "rgba(#{bc.join(", ")})"
94
+ when Range
95
+ "linear-gradient(45deg, #{bc.first}, #{bc.last})"
96
+ when ->(value) { File.exist?(value) }
97
+ "url(data:image/png;base64,#{encode_file_to_base64(bc)})"
98
+ else
99
+ bc
100
+ end
101
+
102
+ styles.merge(background: color)
103
+ end
104
+
105
+ SPACING_DIRECTIONS = [:left, :right, :top, :bottom]
106
+
107
+ # We extract the appropriate margin and padding from the margin and
108
+ # padding properties. If there are no margin or padding properties,
109
+ # we fall back to props["options"] margin or padding, if it exists.
110
+ #
111
+ # Margin or padding (in either props or props["options"]) can be
112
+ # a Hash with directions as keys, or an Array of left/right/top/bottom,
113
+ # or a constant, which means all four are that constant. You can
114
+ # also specify a "margin" plus "margin-top" which is constant but
115
+ # margin-top is overridden, or similar.
116
+ #
117
+ # If any margin or padding property exists in props then we don't
118
+ # check props["options"].
119
+ def spacing_styles_for_attr(attr, props, styles, with_options: true)
120
+ spacing_styles = {}
121
+
122
+ case props[attr]
123
+ when Hash
124
+ props[attr].each do |dir, value|
125
+ spacing_styles[:"#{attr}-#{dir}"] = dimensions_length value
126
+ end
127
+ when Array
128
+ SPACING_DIRECTIONS.zip(props[attr]).to_h.compact.each do |dir, value|
129
+ spacing_styles[:"#{attr}-#{dir}"] = dimensions_length(value)
130
+ end
131
+ when String, Numeric
132
+ spacing_styles[attr.to_sym] = dimensions_length(props[attr])
133
+ end
134
+
135
+ SPACING_DIRECTIONS.each do |dir|
136
+ if props["#{attr}_#{dir}"]
137
+ spacing_styles[:"#{attr}-#{dir}"] = dimensions_length props["#{attr}_#{dir}"]
138
+ end
139
+ end
140
+
141
+ unless spacing_styles.empty?
142
+ return styles.merge(spacing_styles)
143
+ end
144
+
145
+ # We should see if there are spacing properties in props["options"],
146
+ # unless we're currently doing that.
147
+ if with_options && props["options"]
148
+ spacing_styles = spacing_styles_for_attr(attr, props["options"], {}, with_options: false)
149
+ styles.merge spacing_styles
150
+ else
151
+ # No "options" or we already checked it? Return the styles we were given.
152
+ styles
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scarpe::Components::Calzini
4
+ def link_element(props)
5
+ HTML.render do |h|
6
+ h.a(**link_attributes(props)) do
7
+ props["text"]
8
+ end
9
+ end
10
+ end
11
+
12
+ def span_element(props, &block)
13
+ HTML.render do |h|
14
+ h.span(**span_options(props), &block)
15
+ end
16
+ end
17
+
18
+ def code_element(props, &block)
19
+ HTML.render do |h|
20
+ h.code(&block)
21
+ end
22
+ end
23
+
24
+ def em_element(props, &block)
25
+ HTML.render do |h|
26
+ h.em(&block)
27
+ end
28
+ end
29
+
30
+ def strong_element(props, &block)
31
+ HTML.render do |h|
32
+ h.strong(&block)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def link_attributes(props)
39
+ {
40
+ id: html_id,
41
+ href: props["click"],
42
+ onclick: (handler_js_code("click") if props["has_block"]),
43
+ style: drawable_style(props),
44
+ }.compact
45
+ end
46
+
47
+ def span_style(props)
48
+ {
49
+ color: props["stroke"],
50
+ "font-size": span_font_size(props),
51
+ "font-family": props["font"],
52
+ }.compact
53
+ end
54
+
55
+ def span_options(props)
56
+ (props["html_attributes"] || {}).merge(id: html_id, style: span_style(props))
57
+ end
58
+
59
+ def span_font_size(props)
60
+ sz = props["size"]
61
+ font_size = SIZES.key?(sz.to_s.to_sym) ? SIZES[sz.to_s.to_sym] : sz
62
+
63
+ dimensions_length(font_size)
64
+ end
65
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "html"
4
+ require_relative "base64"
5
+ require_relative "errors"
6
+
7
+ # Require all drawable rendering code under calzini directory
8
+ Dir.glob("calzini/*.rb", base: __dir__) do |drawable|
9
+ require_relative drawable
10
+ end
11
+
12
+ # The Calzini module expects to be included by a class defining
13
+ # the following methods:
14
+ #
15
+ # * html_id - the HTML ID for the specific rendered DOM object
16
+ # * handler_js_code(event_name) - the JS handler code for this DOM object and event name
17
+ # * (optional) shoes_styles - the Shoes styles for this object, unless overridden in render()
18
+ module Scarpe::Components::Calzini
19
+ extend self
20
+
21
+ HTML = Scarpe::Components::HTML
22
+ include Scarpe::Components::Base64
23
+
24
+ SIZES = {
25
+ inscription: 10,
26
+ ins: 10,
27
+ para: 12,
28
+ caption: 14,
29
+ tagline: 18,
30
+ subtitle: 26,
31
+ title: 34,
32
+ banner: 48,
33
+ }.freeze
34
+ private_constant :SIZES
35
+
36
+ # Render the Shoes drawable of type `drawable_name` with
37
+ # the given properties to HTML and return it. If the
38
+ # drawable type takes a block (e.g. Stack or Flow) then
39
+ # the block will be properly rendered.
40
+ #
41
+ # @param drawable_name [String] the drawable name like "alert", "button" or "rect"
42
+ # @param properties [Hash] a drawable-specific hash of property names to values
43
+ # @block the block which, when called, will return the contents for drawable types with contents
44
+ # @return [String] the rendered HTML
45
+ def render(drawable_name, properties = shoes_styles, &block)
46
+ send("#{drawable_name}_element", properties, &block)
47
+ end
48
+
49
+ # Return HTML for an empty page element, to be filled with HTML
50
+ # renderings of the DOM tree.
51
+ #
52
+ # The wrapper-wvroot element is where Scarpe will fill in the
53
+ # DOM element.
54
+ #
55
+ # @return [String] the rendered HTML for the empty page object.
56
+ def empty_page_element
57
+ <<~HTML
58
+ <html>
59
+ <head id='head-wvroot'>
60
+ <style id='style-wvroot'>
61
+ /** Style resets **/
62
+ body {
63
+ font-family: arial, Helvetica, sans-serif;
64
+ margin: 0;
65
+ height: 100%;
66
+ overflow: hidden;
67
+ }
68
+ p {
69
+ margin: 0;
70
+ }
71
+ </style>
72
+ </head>
73
+ <body id='body-wvroot'>
74
+ <div id='wrapper-wvroot'></div>
75
+ </body>
76
+ </html>
77
+ HTML
78
+ end
79
+
80
+ def text_size(sz)
81
+ case sz
82
+ when Numeric
83
+ sz
84
+ when Symbol
85
+ SIZES[sz]
86
+ when String
87
+ SIZES[sz.to_sym] || sz.to_i
88
+ else
89
+ raise "Unexpected text size object: #{sz.inspect}"
90
+ end
91
+ end
92
+
93
+ def dimensions_length(value)
94
+ case value
95
+ when Integer
96
+ if value < 0
97
+ "calc(100% - #{value.abs}px)"
98
+ else
99
+ "#{value}px"
100
+ end
101
+ when Float
102
+ "#{value * 100}%"
103
+ else
104
+ value
105
+ end
106
+ end
107
+
108
+ def drawable_style(props)
109
+ styles = {}
110
+ if props["hidden"]
111
+ styles[:display] = "none"
112
+ end
113
+ styles
114
+ end
115
+
116
+ # Convert an [r, g, b, a] array to an HTML hex color code
117
+ # Arrays support alpha. HTML hex does not. So premultiply.
118
+ def rgb_to_hex(color)
119
+ return color if color.nil?
120
+
121
+ r, g, b, a = *color
122
+ if r.is_a?(Float)
123
+ a ||= 1.0
124
+ r_float = r * a
125
+ g_float = g * a
126
+ b_float = b * a
127
+ else
128
+ a ||= 255
129
+ a_float = (a / 255.0)
130
+ r_float = (r.to_f / 255.0) * a_float
131
+ g_float = (g.to_f / 255.0) * a_float
132
+ b_float = (b.to_f / 255.0) * a_float
133
+ end
134
+
135
+ r_int = (r_float * 255.0).to_i.clamp(0, 255)
136
+ g_int = (g_float * 255.0).to_i.clamp(0, 255)
137
+ b_int = (b_float * 255.0).to_i.clamp(0, 255)
138
+
139
+ "#%0.2X%0.2X%0.2X" % [r_int, g_int, b_int]
140
+ end
141
+
142
+ def degrees_to_radians(degrees)
143
+ degrees * Math::PI / 180
144
+ end
145
+
146
+ def radians_to_degrees(radians)
147
+ radians * (180.0 / Math::PI)
148
+ end
149
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Also defined in scarpe_core
4
+ class Scarpe::Error < StandardError; end
5
+
6
+ module Scarpe
7
+ class InternalError < Scarpe::Error; end
8
+
9
+ class FileContentError < Scarpe::Error; end
10
+
11
+ class NoOperationError < Scarpe::Error; end
12
+
13
+ class DuplicateFileError < Scarpe::Error; end
14
+
15
+ class NoSuchFile < Scarpe::Error; end
16
+
17
+ class MustOverrideMethod < Scarpe::Error; end
18
+
19
+ class InvalidHTMLTag < Scarpe::Error; end
20
+ end
@@ -4,6 +4,7 @@ require "tempfile"
4
4
 
5
5
  # These can be used for unit tests, but also more generally.
6
6
 
7
+ module Scarpe; module Components; end; end
7
8
  module Scarpe::Components::FileHelpers
8
9
  # Create a temporary file with the given prefix and contents.
9
10
  # Execute the block of code with it in place. Make sure
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Scarpe::Components::HTML
4
+ CONTENT_TAGS = [
5
+ :div,
6
+ :p,
7
+ :button,
8
+ :ul,
9
+ :li,
10
+ :textarea,
11
+ :a,
12
+ :video,
13
+ :strong,
14
+ :style,
15
+ :progress,
16
+ :em,
17
+ :code,
18
+ :defs,
19
+ :marker,
20
+ :u,
21
+ :line,
22
+ :span,
23
+ :svg,
24
+ :h1,
25
+ :h2,
26
+ :h3,
27
+ :h4,
28
+ :h5,
29
+ ].freeze
30
+ VOID_TAGS = [:input, :img, :polygon, :source, :link, :path, :rect].freeze
31
+
32
+ TAGS = (CONTENT_TAGS + VOID_TAGS).freeze
33
+
34
+ class << self
35
+ def render(&block)
36
+ new(&block).value
37
+ end
38
+ end
39
+
40
+ def initialize(&block)
41
+ @buffer = ""
42
+ block.call(self)
43
+ end
44
+
45
+ def value
46
+ @buffer
47
+ end
48
+
49
+ def respond_to_missing?(name, include_all = false)
50
+ TAGS.include?(name) || super(name, include_all)
51
+ end
52
+
53
+ def p(*args, &block)
54
+ method_missing(:p, *args, &block)
55
+ end
56
+
57
+ def option(**attrs, &block)
58
+ tag(:option, **attrs, &block)
59
+ end
60
+
61
+ def tag(name, **attrs, &block)
62
+ if VOID_TAGS.include?(name)
63
+ raise Shoes::Errors::InvalidAttributeValueError, "void tag #{name} cannot have content" if block_given?
64
+
65
+ @buffer += "<#{name}#{render_attributes(attrs)} />"
66
+ else
67
+ @buffer += "<#{name}#{render_attributes(attrs)}>"
68
+
69
+ if block_given?
70
+ result = block.call(self)
71
+ else
72
+ result = attrs[:content]
73
+ @buffer += result if result.is_a?(String)
74
+ end
75
+ @buffer += result if result.is_a?(String)
76
+
77
+ @buffer += "</#{name}>"
78
+ end
79
+
80
+ nil
81
+ end
82
+
83
+ def select(**attrs, &block)
84
+ tag(:select, **attrs, &block)
85
+ end
86
+
87
+ def method_missing(name, *args, &block)
88
+ raise Scarpe::InvalidHTMLTag, "no method #{name} for #{self.class.name}" unless TAGS.include?(name)
89
+
90
+ if VOID_TAGS.include?(name)
91
+ raise Shoes::Errors::InvalidAttributeValueError, "void tag #{name} cannot have content" if block_given?
92
+
93
+ @buffer += "<#{name}#{render_attributes(*args)} />"
94
+ else
95
+ @buffer += "<#{name}#{render_attributes(*args)}>"
96
+
97
+ if block_given?
98
+ result = block.call(self)
99
+ else
100
+ result = args.first
101
+ @buffer += result if result.is_a?(String)
102
+ end
103
+ @buffer += result if result.is_a?(String)
104
+
105
+ @buffer += "</#{name}>"
106
+ end
107
+
108
+ nil
109
+ end
110
+
111
+ private
112
+
113
+ def render_attributes(attributes = {})
114
+ return "" if attributes.empty?
115
+
116
+ attributes[:style] = render_style(attributes[:style]) if attributes[:style]
117
+ attributes.compact!
118
+
119
+ return if attributes.empty?
120
+
121
+ result = attributes.map { |k, v| "#{k}=\"#{v}\"" }.join(" ")
122
+ " #{result}"
123
+ end
124
+
125
+ def render_style(style)
126
+ return style unless style.is_a?(Hash)
127
+ return if style.empty?
128
+
129
+ style.map { |k, v| "#{k}:#{v}" }.join(";")
130
+ end
131
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Have to require this to get DefaultReporter and the Minitest::Reporters namespace.
4
+ ENV["MINITEST_REPORTER"] = "ShoesExportReporter"
5
+ require "minitest/reporters"
6
+ require "json"
7
+ require "json/add/exception"
8
+
9
+ module Minitest
10
+ module Reporters
11
+ # To use this Scarpe component, you'll need minitest-reporters in your Gemfile,
12
+ # probably in the "test" group. You'll need to require and activate ShoesExportReporter
13
+ # to register it as Minitest's reporter:
14
+ #
15
+ # require "scarpe/components/minitest_export_reporter"
16
+ # Minitest::Reporters::ShoesExportReporter.activate!
17
+ #
18
+ # Select a destination to export JSON test results to:
19
+ #
20
+ # export SHOES_MINITEST_EXPORT_FILE=/tmp/shoes_test_export.json
21
+ #
22
+ # This class overrides the MINITEST_REPORTER environment variable when you call activate.
23
+ # If MINITEST_REPORTER isn't set then when you run via Vim, TextMate, RubyMine, etc,
24
+ # the reporter will be automatically overridden and print to console instead.
25
+ #
26
+ # Based on https://gist.github.com/davidwessman/09a13840a8a80080e3842ac3051714c7
27
+ class ShoesExportReporter < DefaultReporter
28
+ def self.activate!
29
+ unless ENV["SHOES_MINITEST_EXPORT_FILE"]
30
+ raise "ShoesExportReporter is available, but no export file was specified! Set SHOES_MINITEST_EXPORT_FILE!"
31
+ end
32
+
33
+ Minitest::Reporters.use!
34
+ end
35
+
36
+ def serialize_failures(failures)
37
+ failures.map do |fail|
38
+ case fail
39
+ when Minitest::UnexpectedError
40
+ ["unexpected", fail.to_json, fail.error.to_json]
41
+ when Exception
42
+ ["exception", fail.to_json]
43
+ else
44
+ raise "Not sure how to serialize failure object! #{fail.inspect}"
45
+ end
46
+ end
47
+ end
48
+
49
+ def report
50
+ super
51
+
52
+ results = tests.map do |result|
53
+ failures = serialize_failures result.failures
54
+ {
55
+ name: result.name,
56
+ klass: test_class(result),
57
+ assertions: result.assertions,
58
+ failures: failures,
59
+ time: result.time,
60
+ metadata: result.respond_to?(:metadata) ? result.metadata : {},
61
+ source_location: begin
62
+ result.source_location
63
+ rescue
64
+ ["unknown", -1]
65
+ end,
66
+ }
67
+ end
68
+
69
+ out_file = File.expand_path ENV["SHOES_MINITEST_EXPORT_FILE"]
70
+ puts "Writing Minitest results to #{out_file.inspect}."
71
+ File.write(out_file, JSON.dump(results))
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,98 @@
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
+ module Scarpe::Components::ImportRunnables
9
+ # Minitest Runnables are unusual - we expect to declare a class (like a Test) with
10
+ # a lot of methods to run. The ImportRunnable is a single Runnable. But whenever
11
+ # you tell it to import a JSON file, it will add all of the described tests to
12
+ # its runnable methods.
13
+ #
14
+ # Normally that means that your subclass tests will run up front and produce
15
+ # JSON files, then Minitest will autorun at the end and report all their
16
+ # results.
17
+ #
18
+ # It wouldn't really make sense to create these runnables during the testing
19
+ # phase, because Minitest has already decided what to run at that point.
20
+ class ImportRunnable #< Minitest::Runnable
21
+ # Import JSON from an exported Minitest run. Note that running this multiple
22
+ # times with overlapping class names may be really bad.
23
+ def self.import_json_data(data)
24
+ @imported_classes ||= {}
25
+ @imported_tests ||= {}
26
+
27
+ JSON.parse(data).each do |item|
28
+ klass = item["klass"]
29
+ meth = item["name"]
30
+ @imported_tests[klass] ||= {}
31
+ @imported_tests[klass][meth] = item
32
+ end
33
+
34
+ @imported_tests.each do |klass_name, test_method_hash|
35
+ klass = @imported_classes[klass_name]
36
+ unless klass
37
+ new_klass = Class.new(Minitest::Runnable)
38
+ @imported_classes[klass_name] = new_klass
39
+ ImportRunnable.const_set(klass_name, new_klass)
40
+ klass = new_klass
41
+
42
+ klass.define_singleton_method(:run_one_method) do |klass, method_name, reporter|
43
+ reporter.prerecord klass, method_name
44
+ imp = test_method_hash[method_name]
45
+
46
+ res = Minitest::Result.new imp["name"]
47
+ res.klass = imp["klass"]
48
+ res.assertions = imp["assertions"]
49
+ res.time = imp["time"]
50
+ res.failures = ImportRunnable.deserialize_failures imp["failures"]
51
+ res.metadata = imp["metadata"] if imp["metadata"]
52
+
53
+ # Record the synthetic result built from imported data
54
+ reporter.record res
55
+ end
56
+ end
57
+
58
+ # Update "runnables" method to reflect all current known runnable tests
59
+ klass_methods = test_method_hash.keys
60
+ klass.define_singleton_method(:runnable_methods) do
61
+ klass_methods
62
+ end
63
+ end
64
+ end
65
+
66
+ def self.json_to_err(err_json)
67
+ klass = begin
68
+ Object.const_get(err_json["json_class"])
69
+ rescue
70
+ nil
71
+ end
72
+ if klass && klass <= Minitest::Assertion
73
+ klass.json_create(err_json)
74
+ else
75
+ err = Exception.json_create(err_json)
76
+ Minitest::UnexpectedError.new(err)
77
+ end
78
+ end
79
+
80
+ def self.deserialize_failures(failures)
81
+ failures.map do |fail|
82
+ # Instantiate the Minitest::Assertion or Minitest::UnexpectedError
83
+ if fail[0] == "exception"
84
+ exc_json = JSON.parse(fail[1])
85
+ json_to_err exc_json
86
+ elsif fail[0] == "unexpected"
87
+ unexpected_json = JSON.parse(fail[1])
88
+ inner_json = JSON.parse(fail[2])
89
+ outer_err = json_to_err unexpected_json
90
+ inner_err = json_to_err inner_json
91
+ outer_err.error = inner_err
92
+ else
93
+ raise "Unknown exception data when trying to deserialize! #{fail.inspect}"
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end