compex 0.1.1

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,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompEx
4
+ class ComponentRegistry
5
+ Node = Struct.new(:name, :children, :component) do
6
+ def initialize(*)
7
+ super
8
+ self.children ||= []
9
+ end
10
+ end
11
+
12
+ class << self
13
+ attr_reader :components, :tree
14
+
15
+ def register(component)
16
+ @components ||= {}
17
+ @tree ||= Struct.new(:children).new(children: [])
18
+
19
+ stable_id = CompEx::Base.stable_class_id(component)
20
+ @components[stable_id] = component
21
+
22
+ names = stable_id.split("::")
23
+ node = @tree
24
+ until names.empty?
25
+ name = names.shift
26
+ target = node.children.find { it.name == name }
27
+ unless target
28
+ target = Node.new(name)
29
+ node.children << target
30
+ end
31
+ if names.empty?
32
+ warn "[CompEx] Component name collision: #{component.name} vs #{target.component.name}" if target.component
33
+ target.component = component
34
+ end
35
+ node = target
36
+ end
37
+ end
38
+
39
+ def find_by_path(path, base = nil)
40
+ base ||= @tree
41
+ return base if path.empty?
42
+
43
+ ret = base.children.find { it.name == path.first }
44
+ return nil unless ret
45
+
46
+ find_by_path(path[1...], ret)
47
+ end
48
+
49
+ def resolve(name, context: nil)
50
+ return nil if name.nil? || name.empty?
51
+
52
+ # Normalize
53
+ absolute = name.start_with?("::")
54
+ path = name.gsub(/^::/, "").split("::")
55
+
56
+ # Absolute lookup always from root
57
+ if absolute
58
+ node = find_by_path(path, @tree)
59
+ return node&.component
60
+ end
61
+
62
+ # Relative lookup: walk up context nesting
63
+ if context
64
+ namespaces = context.name.split("::")
65
+ until namespaces.empty?
66
+ base = find_by_path(namespaces, @tree)
67
+ if base
68
+ node = find_by_path(path, base)
69
+ return node&.component if node&.component
70
+ end
71
+ namespaces.pop
72
+ end
73
+ end
74
+
75
+ # Fallback: top-level
76
+ node = find_by_path(path, @tree)
77
+ node&.component
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompEx
4
+ module_function
5
+
6
+ CONFIG_MU = Mutex.new
7
+
8
+ def configure(&)
9
+ CONFIG_MU.synchronize do
10
+ @config ||= Config.new
11
+ yield @config if block_given?
12
+ @config
13
+ end
14
+ end
15
+
16
+ def config = configure
17
+
18
+ class Config
19
+ attr_accessor :on_multiple_root, :multiple_root_wrap_element,
20
+ :on_non_element, :cache_args, :cache_kwargs, :cache_prefix,
21
+ :template_search_path, :js_search_path, :style_search_path
22
+ attr_reader :cache_mode, :cache_backend
23
+
24
+ CACHE_MODES = {
25
+ memcached: Cache::MemcachedBackend,
26
+ redis: Cache::RedisBackend,
27
+ memory: Cache::MemoryBackend,
28
+ disabled: Cache::NoopBackend
29
+ }.freeze
30
+
31
+ def initialize
32
+ @on_multiple_root = :wrap
33
+ @multiple_root_wrap_element = "div"
34
+ @on_non_element = :wrap
35
+ self.cache_mode = :memory
36
+
37
+ return unless defined?(Rails)
38
+
39
+ root_path = File.join(Rails.root, "app", "views", "components")
40
+ @template_search_path = root_path
41
+ @style_search_path = root_path
42
+ @js_search_path = root_path
43
+ end
44
+
45
+ def cache_mode=(value)
46
+ value = value.to_sym if value.is_a? String
47
+ raise ArgumentError, "Unknown cache mode #{value}, valid options are #{CACHE_MODES.keys.map(&:to_s).join(", ")}" unless CACHE_MODES.key? value
48
+
49
+ @cache_backend = CACHE_MODES[value].new
50
+ @cache_backend.prepare!(*cache_args, **cache_kwargs)
51
+ end
52
+
53
+ def lookup_template(component, type)
54
+ @lookup_cache ||= {}
55
+ lookup_key = [component, type]
56
+ return @lookup_cache[lookup_key] if @lookup_cache.key? lookup_key
57
+
58
+ cname = component.component_name
59
+ raise MissingTemplate, "No template defined by anonymous class #{self.class} (through ::#{type})" if type == :html && !cname
60
+
61
+ # Anonymous classes can't be looked up since we can't define its path
62
+ unless cname
63
+ @lookup_cache[lookup_key] = nil
64
+ return nil
65
+ end
66
+
67
+ ret = case type
68
+ when :template
69
+ File.join(@template_search_path || "", "#{cname}.html.crb")
70
+ when :style
71
+ File.join(@style_search_path || "", "#{cname}.css")
72
+ when :js
73
+ File.join(@js_search_path || "", "#{cname}.js")
74
+ else
75
+ raise "Unexpected lookup type #{type.inspect}"
76
+ end
77
+
78
+ @lookup_cache[lookup_key] = ret
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompEx
4
+ class JSRewriter
5
+ def self.rewrite(component, raw_js)
6
+ return nil unless raw_js
7
+
8
+ class_name = component.js_class_id
9
+ source = raw_js.strip.gsub(/\Aclass\s+[^\s]+\s*{/, "").gsub(/}\z/, "")
10
+ <<~JS
11
+ ((Runtime, Component) => {
12
+ class #{class_name} extends Component {#{source}}
13
+ Runtime.register(#{class_name.inspect}, #{class_name});
14
+ })(window.CompEx.Runtime, window.CompEx.Component);
15
+ JS
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompEx
4
+ module StringRefinements
5
+ refine String do
6
+ def underscore
7
+ gsub("::", "/")
8
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
9
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
10
+ .tr("-", "_")
11
+ .downcase
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompEx
4
+ class Style
5
+ def self.compile(component_class, root_classes = nil)
6
+ [
7
+ component_prefix(component_class),
8
+ new(component_class, root_classes).compile
9
+ ]
10
+ end
11
+
12
+ def self.component_prefix(component) = "cx_#{hash_string(comp_id(component))}"
13
+
14
+ def self.comp_id(comp) = comp.class.name || comp.class.hash.to_s(36)
15
+
16
+ def self.hash_string(str)
17
+ value = 2906
18
+ str.chars.map(&:ord).each do |v|
19
+ value = (value * 33) ^ v
20
+ end
21
+
22
+ (value & 0xFFFFFFFF).to_s(36)
23
+ end
24
+
25
+ def initialize(klass, root_classes = nil)
26
+ @comp = klass
27
+ @style = klass.style
28
+ @ast = MiniCSS.parse(@style)
29
+ @root_classes = root_classes || []
30
+ end
31
+
32
+ def id_for_component = self.class.component_prefix(@comp)
33
+
34
+ def compile
35
+ @ast.each do |v|
36
+ case v
37
+ when MiniCSS::AST::Rule
38
+ next unless v.valid_selector?
39
+
40
+ v.selector = prefix_selector(v.selector)
41
+ when MiniCSS::AST::AtRule
42
+ next unless %w[media supports].include?(v.name)
43
+
44
+ v.child_rules = v.child_rules.map do |rule|
45
+ next rule unless rule.is_a? MiniCSS::AST::Rule
46
+ next rule unless rule.valid_selector?
47
+
48
+ rule.selector = prefix_selector(rule.selector)
49
+ rule
50
+ end
51
+ else
52
+ raise "Unexpected AST element #{v}"
53
+ end
54
+ end
55
+ MiniCSS.serialize(@ast)
56
+ end
57
+
58
+ def prefix_selector(sel)
59
+ case sel[:type]
60
+ when :class
61
+ {
62
+ type: :complex,
63
+ combinator: @root_classes.include?(sel[:content].gsub(/^\./, "")) ? "" : " ",
64
+ left: { type: :class, content: ".#{id_for_component}" },
65
+ right: sel
66
+ }
67
+ when :type, :attribute, :universal, :id
68
+ {
69
+ type: :complex,
70
+ combinator: " ",
71
+ left: { type: :class, content: ".#{id_for_component}" },
72
+ right: sel
73
+ }
74
+ when :list
75
+ sel.tap do |s|
76
+ s[:list].map! { prefix_selector(it) }
77
+ end
78
+ when :complex
79
+ sel.tap do |s|
80
+ s[:left] = prefix_selector(s[:left])
81
+ end
82
+ when :compound
83
+ sel.tap do |s|
84
+ s[:list][0] = prefix_selector(s[:list].first)
85
+ end
86
+ else
87
+ raise "Unexpected selector type #{sel[:type]}"
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompEx
4
+ class Template
5
+ class Assembler
6
+ HTML_VOID_TAG = %w[area base br col embed hr img input link meta param source track wbr].freeze
7
+ ELEMENTS_WITH_SELF_CLOSING_CHILDREN = %w[svg math].freeze
8
+
9
+ attr_accessor :context_id, :embeddings, :components, :result
10
+
11
+ def initialize(ast, context_id)
12
+ @ast = ast
13
+ @embeddings = {}
14
+ @components = {}
15
+ @result = []
16
+ @literal = +""
17
+ @context_id = context_id
18
+ @embedding_id = 0
19
+ @component_id = 0
20
+ end
21
+
22
+ def assemble
23
+ @ast.each { assemble_one(it) }
24
+ push_literal
25
+ @result
26
+ end
27
+
28
+ def push_literal
29
+ return if @literal.empty?
30
+
31
+ @result << { kind: :literal, value: @literal }
32
+ @literal = +""
33
+ end
34
+
35
+ COMPONENT_NAME_PATTERN = /^(::)?[A-Z]\w*+(::[A-Z]\w*)*$/
36
+
37
+ def component?(node) = COMPONENT_NAME_PATTERN.match?(node.name)
38
+
39
+ def make_component(node)
40
+ name = "compex-component-#{@component_id}"
41
+ @component_id += 1
42
+ @components[name] = node.name
43
+ name
44
+ end
45
+
46
+ def assemble_one(node)
47
+ case node
48
+ when MiniHTML::AST::PlainText
49
+ @literal << node.literal
50
+ when MiniHTML::AST::Tag
51
+ name = component?(node) ? make_component(node) : node.name
52
+ @literal << "<#{name}"
53
+ has_attrs = !node.attributes.empty?
54
+ if has_attrs
55
+ @literal << " "
56
+ process_custom_events(node)
57
+ node.attributes.each { assemble_attr(it) }
58
+ end
59
+
60
+ if node.self_closing? && (@allow_self_closing || HTML_VOID_TAG.include?(name))
61
+ @literal << " " if has_attrs
62
+ @literal << "/>"
63
+ return
64
+ elsif node.self_closing?
65
+ @literal << "></#{name}>"
66
+ return
67
+ end
68
+
69
+ @literal << ">"
70
+
71
+ if ELEMENTS_WITH_SELF_CLOSING_CHILDREN.include?(name)
72
+ assemble_children_self_closing(node)
73
+ else
74
+ node.children.each { assemble_one(it) }
75
+ end
76
+
77
+ @literal << "</#{name}>"
78
+
79
+ when MiniHTML::AST::Executable
80
+ push_literal
81
+ id = "__cx_embedding_#{@context_id}_#{@embedding_id}__"
82
+ @embedding_id += 1
83
+ @embeddings[id] = node.source
84
+ @result << { kind: :executable, id:, source: node.source }
85
+ else
86
+ raise "Unexpected node type #{node.class}"
87
+ end
88
+ end
89
+
90
+ def assemble_attr(at)
91
+ @literal << at.name
92
+ if at.value.nil?
93
+ @literal << " "
94
+ return
95
+ end
96
+ @literal << "="
97
+ case at.value
98
+ when MiniHTML::AST::String
99
+ @literal << at.value.quote
100
+ @literal << at.value.literal
101
+ @literal << at.value.quote
102
+
103
+ when MiniHTML::AST::Executable
104
+ push_literal
105
+ id = @embedding_id
106
+ @embedding_id += 1
107
+ @result << { kind: :executable, id: "__cx_embedding_quoted_#{@context_id}_#{id}__", source: at.value.source }
108
+
109
+ when MiniHTML::AST::Interpolation
110
+ assemble_interpolation(at.value)
111
+
112
+ when MiniHTML::AST::Literal
113
+ @literal << at.value.value
114
+
115
+ else
116
+ raise "Unexpected attr value node type #{at.value.class}"
117
+ end
118
+ end
119
+
120
+ def assemble_children_self_closing(node)
121
+ old_self_closing = @allow_self_closing
122
+ @allow_self_closing = true
123
+ node.children.each { assemble_one(it) }
124
+ ensure
125
+ @allow_self_closing = old_self_closing
126
+ end
127
+
128
+ def assemble_interpolation(int)
129
+ @literal << int.values.first.quote
130
+ @literal << int.values.first.literal
131
+
132
+ int.values[1...].each do |v|
133
+ case v
134
+ when MiniHTML::AST::String
135
+ @literal << v.literal
136
+ when MiniHTML::AST::Executable
137
+ push_literal
138
+ id = @embedding_id
139
+ @embedding_id += 1
140
+ @result << { kind: :executable, id: "__cx_embedding_#{@context_id}_#{id}__", source: v.source }
141
+ end
142
+ end
143
+
144
+ @literal << int.values.first.quote
145
+ end
146
+
147
+ def process_custom_events(node)
148
+ events = []
149
+ to_remove = []
150
+ node.attributes.each do |at|
151
+ next unless at.name.start_with?("cx-on:")
152
+
153
+ name = at.name.split(":", 2).last
154
+ next unless name
155
+
156
+ handler = at.value.literal
157
+ to_remove << at.name
158
+ if handler.include? ","
159
+ warn "[CompEx] Ignoring custom event definition containing commas: #{at.name}=#{at.value.inspect}"
160
+ next
161
+ end
162
+ events << { name:, handler: }
163
+ end
164
+
165
+ node.attributes.reject! { to_remove.include? it.name }
166
+ return if events.empty?
167
+
168
+ @literal << "cx-custom-handlers="
169
+ @literal << events.map { "#{it[:name]}->#{it[:handler]}" }.join(",").inspect
170
+ @literal << " "
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompEx
4
+ class Template
5
+ class Renderer
6
+ # The following attributes will have their values merged when we
7
+ # find the same tag already defined by an embedded component
8
+ MERGEABLE_ATTRIBUTES = Set.new(%w[class])
9
+
10
+ attr_accessor :tokens, :embeddings, :frag, :root_classes
11
+
12
+ def initialize(assembler, context)
13
+ @tokens = assembler.result
14
+ @embeddings = assembler.embeddings
15
+ @inner_components = assembler.components
16
+ @frag = nil
17
+ @root_classes = []
18
+ @context = context
19
+ @context_id = @context.component_id
20
+ prepare_template
21
+ end
22
+
23
+ def prepare_template
24
+ embeddings.transform_values! do |v|
25
+ RubyVM::InstructionSequence.compile("-> { #{v} }").eval
26
+ end
27
+
28
+ source = @tokens.map do |item|
29
+ case item[:kind]
30
+ when :literal
31
+ item[:value]
32
+ when :executable
33
+ item[:id]
34
+ else
35
+ raise "[BUG] Unexpected token kind #{item[:kind]}"
36
+ end
37
+ end.join
38
+
39
+ @frag = Nokogiri::HTML5.fragment(source.strip)
40
+
41
+ return if @frag.children.empty?
42
+
43
+ if @frag.children.length > 1
44
+ handle_multiple_root
45
+ elsif !@frag.children.first.element?
46
+ handle_non_element
47
+ end
48
+
49
+ @root_classes = @frag.children.first.classes.uniq
50
+ handle_inner_components
51
+ @frag
52
+ end
53
+
54
+ def handle_inner_components
55
+ walk(@frag.children.first) do |node|
56
+ next unless node.name.start_with? "compex-component-"
57
+
58
+ inner_name = @inner_components[node.name]
59
+ next unless inner_name
60
+
61
+ c_node = CompEx::ComponentRegistry.resolve(inner_name, context: @context)
62
+ next unless c_node
63
+
64
+ args = c_node.defined_args.map(&:to_s)
65
+ component_props = node.attributes.slice(*args)
66
+ component_props.each_key { node.remove_attribute(it) }
67
+ child = node.children
68
+ replacement = Nokogiri::HTML5.fragment("<#{node.name}></#{node.name}>")
69
+ node.attributes.each_value { replacement.set_attribute(it.name, it.value) }
70
+ node.replace(replacement)
71
+ @inner_components[node.name] = {
72
+ name: node.name,
73
+ attrs: node.attributes.values,
74
+ children: child,
75
+ args: component_props.values.to_h { [it.name.to_sym, it.value] },
76
+ component: c_node
77
+ }
78
+ end
79
+ end
80
+
81
+ def walk(node, &)
82
+ return unless node.element?
83
+
84
+ yield node # give caller a chance to handle/replace this node
85
+
86
+ node.children.each do |child|
87
+ walk(child, &)
88
+ end
89
+ end
90
+
91
+ def define_css_prefix(prefix)
92
+ @frag.children.first.add_class(prefix)
93
+ end
94
+
95
+ def define_js_module(name)
96
+ @frag.children.first.set_attribute("cx-controller", name)
97
+ end
98
+
99
+ def render(&)
100
+ dom = @frag.dup
101
+ @inner_components.each do |k, v|
102
+ embed = dom.at_css(k)
103
+ next unless embed
104
+
105
+ result = Nokogiri::HTML5.fragment(yield v).children.first
106
+ v[:attrs].each do |attr|
107
+ val = attr.value
108
+ val = "#{result.get_attribute(attr.name)} #{val}" if MERGEABLE_ATTRIBUTES.include?(attr.name) && result.has_attribute?(attr.name)
109
+ result.set_attribute(attr.name, val)
110
+ end
111
+ embed.replace(result)
112
+ end
113
+
114
+ @embeddings.each do |k, v|
115
+ next if k.start_with? "__cx_embedding"
116
+
117
+ embed = dom.at_css("compex-embedding[id=\"#{k}\"]")
118
+ next unless embed
119
+
120
+ result = yield v
121
+ embed.replace(result)
122
+ end
123
+
124
+ dom.to_html
125
+ .gsub(/__cx_embedding_#{@context_id}_\d+__/) { process_embeddings(it, false, &) }
126
+ .gsub(/__cx_embedding_quoted_#{@context_id}_\d+__/) { process_embeddings(it, true, &) }
127
+ end
128
+
129
+ def process_embeddings(key, quoted)
130
+ return key unless @embeddings.key? key
131
+
132
+ ret = yield @embeddings[key]
133
+ ret = ret.inspect if quoted
134
+ ret
135
+ end
136
+
137
+ def handle_multiple_root
138
+ raise MultipleRootError, "Element contains multiple root elements" if CompEx.config.on_multiple_root == :raise
139
+
140
+ wrapper = @frag.document.create_element(CompEx.config.multiple_root_wrap_element)
141
+ @frag.children.each { wrapper << it }
142
+ @frag.add_child(wrapper)
143
+ end
144
+
145
+ def handle_non_element
146
+ raise NonElementRootError, "Element contains multiple root elements" if CompEx.config.on_non_element == :raise
147
+
148
+ wrapper = @frag.document.create_element(CompEx.config.multiple_root_wrap_element)
149
+ @frag.children.first.wrap(wrapper)
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "template/renderer"
4
+ require_relative "template/assembler"
5
+
6
+ module CompEx
7
+ class Template
8
+ def self.parse(value, context)
9
+ p = MiniHTML::Parser.new(value)
10
+ ast = p.parse
11
+ assembler = Assembler.new(ast, context.component_id)
12
+ assembler.assemble
13
+ Renderer.new(assembler, context)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompEx
4
+ class UnsafeString
5
+ attr_reader :raw, :escaped
6
+
7
+ def initialize(value)
8
+ if value.is_a? UnsafeString
9
+ @raw = value.raw
10
+ @escaped = value.escaped
11
+ else
12
+ @raw = value
13
+ @escaped = ERB::Util.html_escape(raw)
14
+ end
15
+ end
16
+
17
+ def to_s = @escaped
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompEx
4
+ VERSION = "0.1.1"
5
+ end
data/lib/compex.rb ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha1"
4
+ require "digest/sha2"
5
+ require "json"
6
+
7
+ require "nokogiri"
8
+ require "minicss"
9
+ require "minihtml"
10
+
11
+ require_relative "compex/version"
12
+ require_relative "compex/template"
13
+ require_relative "compex/string_refinements"
14
+ require_relative "compex/cache"
15
+ require_relative "compex/style"
16
+ require_relative "compex/bag"
17
+ require_relative "compex/js_rewriter"
18
+ require_relative "compex/component_registry"
19
+ require_relative "compex/base"
20
+ require_relative "compex/unsafe_string"
21
+ require_relative "compex/config"
22
+ require_relative "compex/asset_provider"
23
+
24
+ module CompEx
25
+ class Error < StandardError; end
26
+
27
+ class ParsingError < Error
28
+ attr_reader :errors
29
+
30
+ def initialize(errors)
31
+ @errors = errors
32
+ super("Errors were detected parsing the template: #{@errors.map { it[:error] }.join(", ")}")
33
+ end
34
+ end
35
+
36
+ class MultipleRootError < Error; end
37
+ class NonElementRootError < Error; end
38
+ end