scarpe-components 0.1.0 → 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
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ # These can be used for unit tests, but also more generally.
6
+
7
+ module Scarpe; module Components; end; end
8
+ module Scarpe::Components::FileHelpers
9
+ # Create a temporary file with the given prefix and contents.
10
+ # Execute the block of code with it in place. Make sure
11
+ # it gets cleaned up afterward.
12
+ #
13
+ # @param prefix [String] the prefix passed to Tempfile to identify this file on disk
14
+ # @param contents [String] the file contents that should be written to Tempfile
15
+ # @param dir [String] the directory to create the tempfile in
16
+ # @yield The code to execute with the tempfile present
17
+ # @yieldparam the path of the new tempfile
18
+ def with_tempfile(prefix, contents, dir: Dir.tmpdir)
19
+ t = Tempfile.new(prefix, dir)
20
+ t.write(contents)
21
+ t.flush # Make sure the contents are written out
22
+
23
+ yield(t.path)
24
+ ensure
25
+ t.close
26
+ t.unlink
27
+ end
28
+
29
+ # Create multiple tempfiles, with given contents, in given
30
+ # directories, and execute the block in that context.
31
+ # When the block is finished, make sure all tempfiles are
32
+ # deleted.
33
+ #
34
+ # Pass an array of arrays, where each array is of the form:
35
+ # [prefix, contents, (optional)dir]
36
+ #
37
+ # I don't love inlining with_tempfile's contents into here.
38
+ # But calling it iteratively or recursively was difficult
39
+ # when I tried it the obvious ways.
40
+ #
41
+ # This method should be equivalent to calling with_tempfile
42
+ # once for each entry in the array, in a set of nested
43
+ # blocks.
44
+ #
45
+ # @param tf_specs [Array<Array>] The array of tempfile prefixes, contents and directories
46
+ # @yield The code to execute with those tempfiles present
47
+ # @yieldparam An array of paths to tempfiles, in the same order as tf_specs
48
+ def with_tempfiles(tf_specs, &block)
49
+ tempfiles = []
50
+ tf_specs.each do |prefix, contents, dir|
51
+ dir ||= Dir.tmpdir
52
+ t = Tempfile.new(prefix, dir)
53
+ tempfiles << t
54
+ t.write(contents)
55
+ t.flush # Make sure the contents are written out
56
+ end
57
+
58
+ args = tempfiles.map(&:path)
59
+ yield(args)
60
+ ensure
61
+ tempfiles.each do |t|
62
+ t.close
63
+ t.unlink
64
+ end
65
+ end
66
+ end
@@ -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