tailmix 0.2.0 → 0.4.5

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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +3 -0
  3. data/README.md +147 -85
  4. data/app/javascript/tailmix/finder.js +12 -0
  5. data/app/javascript/tailmix/index.js +7 -0
  6. data/app/javascript/tailmix/mutator.js +28 -0
  7. data/app/javascript/tailmix/runner.js +7 -0
  8. data/app/javascript/tailmix/stimulus_adapter.js +37 -0
  9. data/examples/_modal_component.arb +36 -0
  10. data/examples/modal_component.rb +180 -0
  11. data/lib/generators/tailmix/install_generator.rb +19 -0
  12. data/lib/tailmix/definition/context_builder.rb +39 -0
  13. data/lib/tailmix/definition/contexts/action_builder.rb +31 -0
  14. data/lib/tailmix/definition/contexts/actions/element_builder.rb +30 -0
  15. data/lib/tailmix/definition/contexts/attribute_builder.rb +21 -0
  16. data/lib/tailmix/definition/contexts/dimension_builder.rb +16 -0
  17. data/lib/tailmix/definition/contexts/element_builder.rb +41 -0
  18. data/lib/tailmix/definition/contexts/stimulus_builder.rb +101 -0
  19. data/lib/tailmix/definition/merger.rb +93 -0
  20. data/lib/tailmix/definition/result.rb +31 -0
  21. data/lib/tailmix/definition.rb +11 -0
  22. data/lib/tailmix/dev/docs.rb +82 -0
  23. data/lib/tailmix/dev/stimulus_generator.rb +124 -0
  24. data/lib/tailmix/dev/tools.rb +26 -0
  25. data/lib/tailmix/engine.rb +17 -0
  26. data/lib/tailmix/html/attributes.rb +71 -0
  27. data/lib/tailmix/html/class_list.rb +79 -0
  28. data/lib/tailmix/html/data_map.rb +95 -0
  29. data/lib/tailmix/html/stimulus_builder.rb +65 -0
  30. data/lib/tailmix/runtime/action.rb +51 -0
  31. data/lib/tailmix/runtime/context.rb +66 -0
  32. data/lib/tailmix/runtime/facade_builder.rb +23 -0
  33. data/lib/tailmix/runtime/stimulus/compiler.rb +59 -0
  34. data/lib/tailmix/runtime.rb +14 -0
  35. data/lib/tailmix/version.rb +1 -1
  36. data/lib/tailmix.rb +48 -19
  37. metadata +33 -12
  38. data/examples/interactive_component.rb +0 -42
  39. data/examples/status_badge_component.rb +0 -44
  40. data/lib/tailmix/action.rb +0 -27
  41. data/lib/tailmix/dimension.rb +0 -18
  42. data/lib/tailmix/element.rb +0 -24
  43. data/lib/tailmix/manager.rb +0 -86
  44. data/lib/tailmix/part.rb +0 -39
  45. data/lib/tailmix/resolver.rb +0 -28
  46. data/lib/tailmix/schema.rb +0 -41
  47. data/lib/tailmix/utils.rb +0 -15
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "contexts/action_builder"
4
+ require_relative "contexts/element_builder"
5
+
6
+ module Tailmix
7
+ module Definition
8
+ class ContextBuilder
9
+ attr_reader :elements, :actions
10
+
11
+ def initialize
12
+ @elements = {}
13
+ @actions = {}
14
+ end
15
+
16
+ def element(name, base_classes = "", &block)
17
+ builder = Contexts::ElementBuilder.new(name)
18
+ builder.attributes.classes(base_classes.split)
19
+
20
+ builder.instance_eval(&block) if block
21
+
22
+ @elements[name.to_sym] = builder
23
+ end
24
+
25
+ def action(name, method:, &block)
26
+ builder = Contexts::ActionBuilder.new(method)
27
+ builder.instance_eval(&block) if block
28
+ @actions[name.to_sym] = builder
29
+ end
30
+
31
+ def build_definition
32
+ Definition::Result::Context.new(
33
+ elements: @elements.transform_values(&:build_definition).freeze,
34
+ actions: @actions.transform_values(&:build_definition).freeze
35
+ )
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "actions/element_builder"
4
+
5
+ module Tailmix
6
+ module Definition
7
+ module Contexts
8
+ class ActionBuilder
9
+ def initialize(method)
10
+ @method = method
11
+ @mutations = {}
12
+ end
13
+
14
+ def element(name, &block)
15
+ builder = Actions::ElementBuilder.new(@method)
16
+ builder.instance_eval(&block)
17
+
18
+ commands = builder.build_commands
19
+ @mutations[name.to_sym] = commands unless commands.empty?
20
+ end
21
+
22
+ def build_definition
23
+ Definition::Result::Action.new(
24
+ action: @method,
25
+ mutations: @mutations.freeze
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tailmix
4
+ module Definition
5
+ module Contexts
6
+ module Actions
7
+ class ElementBuilder
8
+ def initialize(default_method)
9
+ @default_method = default_method
10
+ @commands = []
11
+ end
12
+
13
+ def classes(classes_string, method: @default_method)
14
+ @commands << { field: :classes, method: method, payload: classes_string }
15
+ end
16
+
17
+ def data(data_hash)
18
+ operation = data_hash.delete(:method) || @default_method
19
+
20
+ @commands << { field: :data, method: operation, payload: data_hash }
21
+ end
22
+
23
+ def build_commands
24
+ @commands
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tailmix
4
+ module Definition
5
+ module Contexts
6
+ class AttributeBuilder
7
+ def initialize
8
+ @classes = []
9
+ end
10
+
11
+ def classes(*list)
12
+ @classes.concat(list.flatten.map(&:to_s))
13
+ end
14
+
15
+ def build_definition
16
+ Definition::Result::Attributes.new(classes: @classes.freeze)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DimensionBuilder
4
+ attr_reader :options
5
+
6
+ def initialize(default: nil)
7
+ @options = { options: {}, default: default }
8
+ end
9
+
10
+ def option(value, classes, default: false)
11
+ @options[:options][value] = classes.split
12
+ if default && @options[:default].nil?
13
+ @options[:default] = value
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "attribute_builder"
4
+ require_relative "stimulus_builder"
5
+ require_relative "dimension_builder"
6
+
7
+ module Tailmix
8
+ module Definition
9
+ module Contexts
10
+ class ElementBuilder
11
+ def initialize(name)
12
+ @name = name
13
+ @dimensions = {}
14
+ end
15
+
16
+ def attributes
17
+ @attributes_builder ||= AttributeBuilder.new
18
+ end
19
+
20
+ def stimulus
21
+ @stimulus_builder ||= StimulusBuilder.new
22
+ end
23
+
24
+ def dimension(name, default: nil, &block)
25
+ dimension = DimensionBuilder.new(default: default)
26
+ dimension.instance_eval(&block)
27
+ @dimensions[name.to_sym] = dimension.options
28
+ end
29
+
30
+ def build_definition
31
+ Definition::Result::Element.new(
32
+ name: @name,
33
+ attributes: attributes.build_definition,
34
+ stimulus: stimulus.build_definition,
35
+ dimensions: @dimensions.freeze
36
+ )
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tailmix
4
+ module Definition
5
+ module Contexts
6
+ class StimulusBuilder
7
+ attr_reader :definitions
8
+
9
+ def initialize
10
+ @definitions = []
11
+ @current_context = nil
12
+ end
13
+
14
+ def controller(name)
15
+ @definitions << { type: :controller, name: name }
16
+ context(name)
17
+ end
18
+ alias_method :ctr, :controller
19
+
20
+ def context(name)
21
+ @current_context = name.to_s
22
+ self
23
+ end
24
+ alias_method :ctx, :context
25
+
26
+ def target(target_name)
27
+ raise "A controller context must be set..." unless @current_context
28
+ @definitions << { type: :target, controller: @current_context, name: target_name }
29
+ self
30
+ end
31
+
32
+ def action(*args)
33
+ ensure_context!
34
+
35
+ action_data = case args.first
36
+ when String
37
+ { type: :raw, content: args.first }
38
+ when Hash
39
+ { type: :hash, content: args.first }
40
+ else
41
+ { type: :tuple, content: args }
42
+ end
43
+
44
+ @definitions << { type: :action, controller: @current_context, data: action_data }
45
+ self
46
+ end
47
+
48
+ def value(value_name, value: nil, call: nil, method: nil)
49
+ ensure_context!
50
+
51
+ source = if !value.nil?
52
+ { type: :literal, content: value }
53
+ elsif call.is_a?(Proc)
54
+ { type: :proc, content: call }
55
+ elsif method.is_a?(Symbol) || method.is_a?(String)
56
+ { type: :method, content: method.to_sym }
57
+ else
58
+ raise ArgumentError, "You must provide one of value:, call:, or method: keyword arguments."
59
+ end
60
+
61
+ @definitions << {
62
+ type: :value,
63
+ controller: @current_context,
64
+ name: value_name,
65
+ source: source
66
+ }
67
+ self
68
+ end
69
+
70
+ def action_payload(action_name, as: nil)
71
+ ensure_context!
72
+ value_name = as || "#{action_name}_action"
73
+
74
+ @definitions << {
75
+ type: :action_payload,
76
+ controller: @current_context,
77
+ action_name: action_name.to_sym,
78
+ value_name: value_name.to_sym
79
+ }
80
+ self
81
+ end
82
+
83
+ def param(params_hash)
84
+ ensure_context!
85
+ @definitions << { type: :param, controller: @current_context, params: params_hash }
86
+ self
87
+ end
88
+
89
+ def build_definition
90
+ Definition::Result::Stimulus.new(definitions: @definitions.freeze)
91
+ end
92
+
93
+ private
94
+
95
+ def ensure_context!
96
+ raise "A controller context must be set via .controller() or .context() before this call." unless @current_context
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tailmix
4
+ module Definition
5
+ # A service object responsible for deep-merging two tailmix definitions.
6
+ class Merger
7
+ def self.call(parent_def, child_def)
8
+ new(parent_def, child_def).merge
9
+ end
10
+
11
+ def initialize(parent_def, child_def)
12
+ @parent_def = parent_def
13
+ @child_def = child_def
14
+ end
15
+
16
+ def merge
17
+ Result::Context.new(
18
+ elements: merged_elements,
19
+ actions: merged_actions
20
+ )
21
+ end
22
+
23
+ private
24
+
25
+ def merged_actions
26
+ # For actions, child definitions completely override parent definitions.
27
+ @parent_def.actions.merge(@child_def.actions)
28
+ end
29
+
30
+ def merged_elements
31
+ all_element_keys = (@parent_def.elements.keys | @child_def.elements.keys)
32
+
33
+ all_element_keys.each_with_object({}) do |key, h|
34
+ parent_element = @parent_def.elements[key]
35
+ child_element = @child_def.elements[key]
36
+
37
+ h[key] = if parent_element && child_element
38
+ merge_element(parent_element, child_element)
39
+ else
40
+ child_element || parent_element
41
+ end
42
+ end
43
+ end
44
+
45
+ def merge_element(parent_el, child_el)
46
+ Result::Element.new(
47
+ name: parent_el.name,
48
+ attributes: merge_attributes(parent_el.attributes, child_el.attributes),
49
+ dimensions: merge_dimensions(parent_el.dimensions, child_el.dimensions),
50
+ stimulus: merge_stimulus(parent_el.stimulus, child_el.stimulus)
51
+ )
52
+ end
53
+
54
+ def merge_attributes(parent_attrs, child_attrs)
55
+ # Combine base classes, ensuring no duplicates.
56
+ combined_classes = (parent_attrs.classes + child_attrs.classes).uniq
57
+ Result::Attributes.new(classes: combined_classes)
58
+ end
59
+
60
+ def merge_stimulus(parent_stimulus, child_stimulus)
61
+ # Combine stimulus definitions.
62
+ combined_definitions = parent_stimulus.definitions + child_stimulus.definitions
63
+ Result::Stimulus.new(definitions: combined_definitions)
64
+ end
65
+
66
+ private
67
+
68
+ def merge_dimensions(parent_dims, child_dims)
69
+ deep_merge(parent_dims, child_dims) do |key, parent_val, child_val|
70
+ if key == :options && parent_val.is_a?(Hash) && child_val.is_a?(Hash)
71
+ parent_val.merge(child_val)
72
+ else
73
+ child_val
74
+ end
75
+ end
76
+ end
77
+
78
+ def deep_merge(parent_hash, child_hash, &block)
79
+ child_hash.each_with_object(parent_hash.dup) do |(key, child_val), new_hash|
80
+ parent_val = new_hash[key]
81
+
82
+ new_hash[key] = if parent_val.is_a?(Hash) && child_val.is_a?(Hash)
83
+ deep_merge(parent_val, child_val, &block)
84
+ elsif block_given? && new_hash.key?(key)
85
+ block.call(key, parent_val, child_val)
86
+ else
87
+ child_val
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tailmix
4
+ module Definition
5
+ module Result
6
+ Context = Struct.new(:elements, :actions, keyword_init: true) do
7
+ def to_h
8
+ {
9
+ elements: elements.transform_values(&:to_h),
10
+ actions: actions.transform_values(&:to_h)
11
+ }
12
+ end
13
+ end
14
+
15
+ Element = Struct.new(:name, :attributes, :dimensions, :stimulus, keyword_init: true) do
16
+ def to_h
17
+ {
18
+ name: name,
19
+ attributes: attributes.to_h,
20
+ dimensions: dimensions,
21
+ stimulus: stimulus.to_h
22
+ }
23
+ end
24
+ end
25
+
26
+ Attributes = Struct.new(:classes, keyword_init: true)
27
+ Stimulus = Struct.new(:definitions, keyword_init: true)
28
+ Action = Struct.new(:action, :mutations, keyword_init: true)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "definition/result"
4
+ require_relative "definition/merger"
5
+ require_relative "definition/context_builder"
6
+
7
+ module Tailmix
8
+ module Definition
9
+
10
+ end
11
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tailmix
4
+ module Dev
5
+ class Docs
6
+
7
+ def initialize(tools)
8
+ @tools = tools
9
+ @definition = tools.definition
10
+ @component_class_name = tools.component_class
11
+ end
12
+
13
+ def generate
14
+ output = ["== Tailmix Docs for #{@component_class_name} =="]
15
+
16
+ signature = generate_signature
17
+ output << "Signature: `initialize(#{signature})`" unless signature.empty?
18
+ output << ""
19
+
20
+ output << generate_dimensions_docs
21
+ output << ""
22
+ output << generate_actions_docs
23
+ output << ""
24
+ output << generate_stimulus_docs
25
+
26
+ output.join("\n")
27
+ end
28
+
29
+ private
30
+
31
+ def generate_signature
32
+ all_dimensions
33
+ .map { |name, config| "#{name}: #{config[:default].inspect}" if config.key?(:default) }
34
+ .compact
35
+ .join(", ")
36
+ end
37
+
38
+ def generate_dimensions_docs
39
+ output = []
40
+
41
+ if all_dimensions.any?
42
+ output << "Dimensions:"
43
+ all_dimensions.each do |dim_name, config|
44
+ default_info = config[:default] ? "(default: #{config[:default].inspect})" : ""
45
+ output << " - #{dim_name} #{default_info}"
46
+ config[:options].each do |option_key, option_value|
47
+ output << " - #{option_key.inspect}: \"#{option_value.join(' ')}\""
48
+ end
49
+ end
50
+ else
51
+ output << "No dimensions defined."
52
+ end
53
+
54
+ output.join("\n")
55
+ end
56
+
57
+ def generate_actions_docs
58
+ output = []
59
+ actions = @definition.actions
60
+
61
+ if actions.any?
62
+ output << "Actions:"
63
+ actions.keys.each do |action_name|
64
+ output << " - :#{action_name}"
65
+ end
66
+ else
67
+ output << "No actions defined."
68
+ end
69
+
70
+ output.join("\n")
71
+ end
72
+
73
+ def generate_stimulus_docs
74
+ @tools.stimulus.scaffold(show_docs: true)
75
+ end
76
+
77
+ def all_dimensions
78
+ @_all_dimensions ||= @definition.elements.values.flat_map(&:dimensions).reduce({}, :merge)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tailmix
4
+ module Dev
5
+ class StimulusGenerator
6
+ def initialize(definition, component_name)
7
+ @definition = definition
8
+ @component_name = component_name
9
+ @stimulus_defs = definition.elements.values.flat_map(&:stimulus).flat_map(&:definitions)
10
+ end
11
+
12
+ def scaffold(controller_name = nil, show_docs: false)
13
+ controllers_to_generate = controller_name ? [ controller_name.to_s ] : all_controllers
14
+
15
+ output = controllers_to_generate.map do |name|
16
+ defs = @stimulus_defs.select { |d| d[:controller] == name }
17
+
18
+ show_docs ? generate_docs_for(name, defs) + "\n" : generate_js_for(name, defs) + ("-" * 60) + "\n"
19
+ end
20
+ output.join("\n")
21
+ end
22
+
23
+ private
24
+
25
+ def generate_docs_for(controller_name, defs)
26
+ output = [ "Stimulus:" ]
27
+ output << " - on `#{controller_name}` controller:"
28
+ targets = defs.select { |d| d[:type] == :target }.map { |d| d[:name] }
29
+ output << " - Targets: #{targets.join(', ')}" if targets.any?
30
+
31
+ actions = action_methods(defs)
32
+ output << " - Actions: #{actions.join(', ')}" if actions.any?
33
+
34
+ values = defs.select { |d| d[:type] == :value }.map { |d| d[:name] }
35
+ output << " - Values: #{values.join(', ')}" if values.any?
36
+
37
+ output.join("\n")
38
+ end
39
+
40
+ def generate_js_for(controller_name, defs)
41
+ targets = defs.select { |d| d[:type] == :target }.map { |d| "'#{d[:name]}'" }.uniq.join(", ")
42
+
43
+ payload_actions = defs.select { |d| d[:type] == :action_payload }
44
+ simple_values = defs.select { |d| d[:type] == :value }
45
+
46
+ value_names = (payload_actions.map { |d| d[:value_name] } + simple_values.map { |d| d[:name] })
47
+ .uniq.map { |name| "#{snake_to_camel(name.to_s)}: Object" }.join(", ")
48
+
49
+ isomorphic_methods = payload_actions.map do |payload_def|
50
+ action_name_camel = snake_to_camel(payload_def[:action_name].to_s)
51
+ value_name_camel = snake_to_camel(payload_def[:value_name].to_s)
52
+
53
+ " #{action_name_camel}(event) {\n if (event) event.preventDefault();\n Tailmix.run({ config: this.#{value_name_camel}Value, controller: this });\n }"
54
+ end.join.strip
55
+
56
+ standard_action_names = defs.select { |d| d[:type] == :action }
57
+ .flat_map { |d| extract_action_methods(d[:data]) }
58
+ .uniq
59
+
60
+ implemented_action_names = payload_actions.map { |d| d[:action_name].to_s }
61
+ stub_methods = (standard_action_names - implemented_action_names).map do |method_name|
62
+ method_name_camel = snake_to_camel(method_name)
63
+ " #{method_name_camel}() {\n console.log('#{controller_name}##{method_name_camel} fired');\n }"
64
+ end.join("\n\n")
65
+
66
+ js_methods = [isomorphic_methods, stub_methods].reject(&:empty?).join("\n\n")
67
+
68
+ <<~JAVASCRIPT
69
+ // Generated by Tailmix for the "#{controller_name}" controller
70
+ // Path: app/javascript/controllers/#{controller_name.tr('_', '-')}_controller.js
71
+ import { Controller } from "@hotwired/stimulus"
72
+ import Tailmix from "tailmix"
73
+
74
+ export default class extends Controller {
75
+ static targets = [#{targets}]
76
+ static values = { #{value_names} }
77
+
78
+ connect() {
79
+ console.log("#{controller_name} controller connected to", this.element);
80
+ }
81
+ #{js_methods}
82
+ }
83
+ JAVASCRIPT
84
+ end
85
+
86
+ def extract_action_methods(action_data)
87
+ case action_data[:type]
88
+ when :raw
89
+ action_data[:content].to_s.scan(/#(\w+)/).flatten
90
+ when :hash
91
+ action_data[:content].values.map(&:to_s)
92
+ when :tuple
93
+ [action_data[:content][1].to_s]
94
+ else
95
+ []
96
+ end
97
+ end
98
+
99
+ def all_controllers
100
+ @stimulus_defs.map { |d| d[:controller] }.compact.uniq
101
+ end
102
+
103
+ def action_methods(defs)
104
+ defs.select { |d| d[:type] == :action }.flat_map do |action_definition|
105
+ data = action_definition[:data]
106
+ case data[:type]
107
+ when :raw
108
+ data[:content].to_s.scan(/#(\w+)/).flatten
109
+ when :hash
110
+ data[:content].values.map(&:to_s)
111
+ when :tuple
112
+ [ data[:content][1].to_s ]
113
+ else
114
+ []
115
+ end
116
+ end.uniq
117
+ end
118
+
119
+ def snake_to_camel(str)
120
+ str.split("_").map.with_index { |word, i| i.zero? ? word : word.capitalize }.join
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "stimulus_generator"
4
+ require_relative "docs"
5
+
6
+ module Tailmix
7
+ module Dev
8
+ class Tools
9
+ attr_reader :definition, :component_class
10
+
11
+ def initialize(component_class)
12
+ @component_class = component_class
13
+ @definition = component_class.tailmix_definition
14
+ end
15
+
16
+ def docs
17
+ Dev::Docs.new(self).generate
18
+ end
19
+ alias_method :help, :docs
20
+
21
+ def stimulus
22
+ StimulusGenerator.new(@definition, @component_class.name)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tailmix
4
+ class Engine < ::Rails::Engine
5
+ config.before_initialize do
6
+ Rails.application.config.assets.paths << Engine.root.join("app/javascript")
7
+ end
8
+
9
+ PRECOMPILE_ASSETS = %w[ index.js runner.js finder.js mutator.js stimulus_adapter.js ]
10
+
11
+ initializer "tailmix.assets" do
12
+ if Rails.application.config.respond_to?(:assets)
13
+ Rails.application.config.assets.precompile += PRECOMPILE_ASSETS
14
+ end
15
+ end
16
+ end
17
+ end