motion 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/generators/motion/install_generator.rb +27 -0
- data/lib/generators/motion/templates/motion.rb +22 -0
- data/lib/generators/motion/templates/motion_controller.js +28 -0
- data/lib/motion/action_cable_extentions/declarative_streams.rb +104 -0
- data/lib/motion/action_cable_extentions/log_suppression.rb +33 -0
- data/lib/motion/action_cable_extentions.rb +13 -0
- data/lib/motion/channel.rb +88 -0
- data/lib/motion/component/broadcasts.rb +88 -0
- data/lib/motion/component/lifecycle.rb +31 -0
- data/lib/motion/component/motions.rb +60 -0
- data/lib/motion/component/rendering.rb +66 -0
- data/lib/motion/component.rb +21 -0
- data/lib/motion/component_connection.rb +98 -0
- data/lib/motion/configuration.rb +106 -0
- data/lib/motion/element.rb +70 -0
- data/lib/motion/errors.rb +148 -0
- data/lib/motion/event.rb +41 -0
- data/lib/motion/log_helper.rb +74 -0
- data/lib/motion/markup_transformer.rb +65 -0
- data/lib/motion/railtie.rb +11 -0
- data/lib/motion/serializer.rb +100 -0
- data/lib/motion/test_helpers.rb +29 -0
- data/lib/motion/version.rb +5 -0
- data/lib/motion.rb +54 -0
- metadata +103 -0
@@ -0,0 +1,106 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "motion"
|
4
|
+
|
5
|
+
module Motion
|
6
|
+
class Configuration
|
7
|
+
class << self
|
8
|
+
attr_reader :options
|
9
|
+
|
10
|
+
def default
|
11
|
+
@default ||= new
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
attr_writer :options
|
17
|
+
|
18
|
+
def option(option, &default)
|
19
|
+
define_option_reader(option, &default)
|
20
|
+
define_option_writer(option)
|
21
|
+
|
22
|
+
self.options = [*options, option].freeze
|
23
|
+
end
|
24
|
+
|
25
|
+
def define_option_reader(option, &default)
|
26
|
+
define_method(option) do
|
27
|
+
if instance_variable_defined?(:"@#{option}")
|
28
|
+
instance_variable_get(:"@#{option}")
|
29
|
+
else
|
30
|
+
instance_variable_set(:"@#{option}", instance_exec(&default))
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def define_option_writer(option)
|
36
|
+
define_method(:"#{option}=") do |value|
|
37
|
+
raise AlreadyConfiguredError if @finalized
|
38
|
+
|
39
|
+
instance_variable_set(:"@#{option}", value)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def initialize
|
45
|
+
yield self if block_given?
|
46
|
+
|
47
|
+
# Ensure a value is selected for all options
|
48
|
+
self.class.options.each(&method(:public_send))
|
49
|
+
|
50
|
+
# Prevent further changes
|
51
|
+
@finalized = true
|
52
|
+
end
|
53
|
+
|
54
|
+
# //////////////////////////////////////////////////////////////////////////
|
55
|
+
|
56
|
+
option :secret do
|
57
|
+
require "rails"
|
58
|
+
|
59
|
+
Rails.application.key_generator.generate_key("motion:secret")
|
60
|
+
end
|
61
|
+
|
62
|
+
option :revision do
|
63
|
+
warn <<~MSG # TODO: Better message (Focus on "How do I fix this?")
|
64
|
+
Motion is automatically inferring the application's revision from git.
|
65
|
+
Depending on your deployment, this may not work for you in production.
|
66
|
+
If it does, add "config.revision = `git rev-parse HEAD`.chomp" to your
|
67
|
+
Motion initializer. If it does not, do something else (probably read an
|
68
|
+
env var or something).
|
69
|
+
MSG
|
70
|
+
|
71
|
+
`git rev-parse HEAD`.chomp
|
72
|
+
end
|
73
|
+
|
74
|
+
option :renderer_for_connection_proc do
|
75
|
+
->(websocket_connection) do
|
76
|
+
require "rack"
|
77
|
+
require "action_controller"
|
78
|
+
|
79
|
+
# Make a special effort to use the host application's base controller
|
80
|
+
# in case the CSRF protection has been customized, but don't couple to
|
81
|
+
# a particular constant from the outer application.
|
82
|
+
controller =
|
83
|
+
if defined?(ApplicationController)
|
84
|
+
ApplicationController
|
85
|
+
else
|
86
|
+
ActionController::Base
|
87
|
+
end
|
88
|
+
|
89
|
+
controller.renderer.new(
|
90
|
+
websocket_connection.env.slice(
|
91
|
+
Rack::HTTP_COOKIE,
|
92
|
+
Rack::RACK_SESSION
|
93
|
+
)
|
94
|
+
)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
option(:stimulus_controller_identifier) { "motion" }
|
99
|
+
option(:key_attribute) { "data-motion-key" }
|
100
|
+
option(:state_attribute) { "data-motion-state" }
|
101
|
+
|
102
|
+
# This is included for completeness. It is not currently used internally by
|
103
|
+
# Motion, but it might be required for building view helpers in the future.
|
104
|
+
option(:motion_attribute) { "data-motion" }
|
105
|
+
end
|
106
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "motion"
|
4
|
+
|
5
|
+
module Motion
|
6
|
+
class Element
|
7
|
+
def self.from_raw(raw)
|
8
|
+
new(raw) if raw
|
9
|
+
end
|
10
|
+
|
11
|
+
attr_reader :raw
|
12
|
+
|
13
|
+
def initialize(raw)
|
14
|
+
@raw = raw.freeze
|
15
|
+
end
|
16
|
+
|
17
|
+
def tag_name
|
18
|
+
raw["tagName"]
|
19
|
+
end
|
20
|
+
|
21
|
+
def value
|
22
|
+
raw["value"]
|
23
|
+
end
|
24
|
+
|
25
|
+
def attributes
|
26
|
+
raw.fetch("attributes", {})
|
27
|
+
end
|
28
|
+
|
29
|
+
def [](key)
|
30
|
+
key = key.to_s
|
31
|
+
|
32
|
+
attributes[key] || attributes[key.tr("_", "-")]
|
33
|
+
end
|
34
|
+
|
35
|
+
def id
|
36
|
+
self[:id]
|
37
|
+
end
|
38
|
+
|
39
|
+
class DataAttributes
|
40
|
+
attr_reader :element
|
41
|
+
|
42
|
+
def initialize(element)
|
43
|
+
@element = element
|
44
|
+
end
|
45
|
+
|
46
|
+
def [](data)
|
47
|
+
element["data-#{data}"]
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
private_constant :DataAttributes
|
52
|
+
|
53
|
+
def data
|
54
|
+
return @data if defined?(@data)
|
55
|
+
|
56
|
+
@data = DataAttributes.new(self)
|
57
|
+
end
|
58
|
+
|
59
|
+
def form_data
|
60
|
+
return @form_data if defined?(@form_data)
|
61
|
+
|
62
|
+
@form_data =
|
63
|
+
ActionController::Parameters.new(
|
64
|
+
Rack::Utils.parse_nested_query(
|
65
|
+
raw.fetch("formData", "")
|
66
|
+
)
|
67
|
+
)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,148 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "motion"
|
4
|
+
|
5
|
+
module Motion
|
6
|
+
class Error < StandardError; end
|
7
|
+
|
8
|
+
class ComponentError < Error
|
9
|
+
attr_reader :component
|
10
|
+
|
11
|
+
def initialize(component, message = nil)
|
12
|
+
super(message)
|
13
|
+
@component = component
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
class ComponentRenderingError < ComponentError; end
|
18
|
+
|
19
|
+
class MotionNotMapped < ComponentError
|
20
|
+
attr_reader :motion
|
21
|
+
|
22
|
+
def initialize(component, motion)
|
23
|
+
super(component, <<~MSG)
|
24
|
+
No component motion handler mapped for motion '#{motion}' in component #{component.class}.
|
25
|
+
|
26
|
+
Fix: Add the following to #{component.class}:
|
27
|
+
|
28
|
+
map_motion :#{motion}
|
29
|
+
MSG
|
30
|
+
|
31
|
+
@motion = motion
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
class BlockNotAllowedError < ComponentRenderingError
|
36
|
+
def initialize(component)
|
37
|
+
super(component, <<~MSG)
|
38
|
+
Motion does not support rendering with a block.
|
39
|
+
|
40
|
+
Fix: Use a plain component and wrap with a motion component.
|
41
|
+
MSG
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
class MultipleRootsError < ComponentRenderingError
|
46
|
+
def initialize(component)
|
47
|
+
super(component, <<~MSG)
|
48
|
+
The template for #{component.class} can only have one root element.
|
49
|
+
|
50
|
+
Fix: Wrap all elements in a single element, such as <div> or <section>.
|
51
|
+
MSG
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
class InvalidComponentStateError < ComponentError; end
|
56
|
+
|
57
|
+
class UnrepresentableStateError < InvalidComponentStateError
|
58
|
+
def initialize(component, cause)
|
59
|
+
super(component, <<~MSG)
|
60
|
+
Some state prevented #{component.class} from being serialized into a
|
61
|
+
string. Motion components must be serializable using Marshal.dump. Many
|
62
|
+
types of objects are not serializable including procs, references to
|
63
|
+
anonymous classes, and more. See the documentation for Marshal.dump for
|
64
|
+
more information.
|
65
|
+
|
66
|
+
Fix: Ensure that any exotic state variables in #{component.class} are
|
67
|
+
removed or replaced.
|
68
|
+
|
69
|
+
The specific (but probably useless) error from Marshal was: #{cause}
|
70
|
+
MSG
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
class SerializedComponentError < Error; end
|
75
|
+
|
76
|
+
class InvalidSerializedStateError < SerializedComponentError
|
77
|
+
def initialize
|
78
|
+
super(<<~MSG)
|
79
|
+
The serialized state of your component is not valid.
|
80
|
+
|
81
|
+
Fix: Ensure that you have not tampered with the DOM.
|
82
|
+
MSG
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
class IncorrectRevisionError < SerializedComponentError
|
87
|
+
attr_reader :expected_revision,
|
88
|
+
:actual_revision
|
89
|
+
|
90
|
+
def initialize(expected_revision, actual_revision)
|
91
|
+
super(<<~MSG)
|
92
|
+
Cannot mount a component from another version of the application.
|
93
|
+
|
94
|
+
Expected revision `#{expected_revision}`;
|
95
|
+
Got `#{actual_revision}`
|
96
|
+
|
97
|
+
Read more: https://github.com/unabridged/motion/wiki/IncorrectRevisionError
|
98
|
+
|
99
|
+
Fix:
|
100
|
+
* Avoid tampering with Motion DOM elements and data attributes (e.g. data-motion-state).
|
101
|
+
* In production, enforce a page refresh for pages with Motion components on deploy.
|
102
|
+
MSG
|
103
|
+
|
104
|
+
@expected_revision = expected_revision
|
105
|
+
@actual_revision = actual_revision
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
class AlreadyConfiguredError < Error
|
110
|
+
def initialize
|
111
|
+
super(<<~MSG)
|
112
|
+
Motion is already configured.
|
113
|
+
|
114
|
+
Fix: Move all Motion config to config/initializers/motion.rb.
|
115
|
+
MSG
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
class IncompatibleClientError < Error
|
120
|
+
attr_reader :expected_version,
|
121
|
+
:actual_version
|
122
|
+
|
123
|
+
def initialize(expected_version, actual_version)
|
124
|
+
super(<<~MSG)
|
125
|
+
Expected client version #{expected_version}, but got #{actual_version}.
|
126
|
+
|
127
|
+
Fix: Run `bin/yarn add @unabridged/motion@#{expected_version}`
|
128
|
+
MSG
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
class BadSecretError < Error
|
133
|
+
attr_reader :minimum_bytes
|
134
|
+
|
135
|
+
def initialize(minimum_bytes)
|
136
|
+
super(<<~MSG)
|
137
|
+
The secret that you provided is not long enough. It must have at least
|
138
|
+
#{minimum_bytes} bytes.
|
139
|
+
MSG
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
class BadRevisionError < Error
|
144
|
+
def initialize
|
145
|
+
super("The revision cannot contain a NULL byte")
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
data/lib/motion/event.rb
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "motion"
|
4
|
+
|
5
|
+
module Motion
|
6
|
+
class Event
|
7
|
+
def self.from_raw(raw)
|
8
|
+
new(raw) if raw
|
9
|
+
end
|
10
|
+
|
11
|
+
attr_reader :raw
|
12
|
+
|
13
|
+
def initialize(raw)
|
14
|
+
@raw = raw.freeze
|
15
|
+
end
|
16
|
+
|
17
|
+
def type
|
18
|
+
raw["type"]
|
19
|
+
end
|
20
|
+
|
21
|
+
alias name type
|
22
|
+
|
23
|
+
def details
|
24
|
+
raw.fetch("details", {})
|
25
|
+
end
|
26
|
+
|
27
|
+
def extra_data
|
28
|
+
raw["extraData"]
|
29
|
+
end
|
30
|
+
|
31
|
+
def target
|
32
|
+
return @target if defined?(@target)
|
33
|
+
|
34
|
+
@target = Motion::Element.from_raw(raw["target"])
|
35
|
+
end
|
36
|
+
|
37
|
+
def form_data
|
38
|
+
target&.form_data
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails"
|
4
|
+
require "active_support/core_ext/string/indent"
|
5
|
+
|
6
|
+
require "motion"
|
7
|
+
|
8
|
+
module Motion
|
9
|
+
class LogHelper
|
10
|
+
BACKTRACE_FRAMES = 5
|
11
|
+
DEFAULT_TAG = "Motion"
|
12
|
+
|
13
|
+
def self.for_channel(channel, logger: channel.connection.logger)
|
14
|
+
new(logger: logger, tag: DEFAULT_TAG)
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.for_component(component, logger: nil)
|
18
|
+
new(logger: logger, tag: "#{component.class}:#{component.object_id}")
|
19
|
+
end
|
20
|
+
|
21
|
+
attr_reader :logger, :tag
|
22
|
+
|
23
|
+
def initialize(logger: nil, tag: nil)
|
24
|
+
@logger = logger || Rails.logger
|
25
|
+
@tag = tag || DEFAULT_TAG
|
26
|
+
end
|
27
|
+
|
28
|
+
def error(message, error: nil)
|
29
|
+
error_info = error ? ":\n#{indent(format_exception(error))}" : ""
|
30
|
+
|
31
|
+
logger.error("[#{tag}] #{message}#{error_info}")
|
32
|
+
end
|
33
|
+
|
34
|
+
def info(message)
|
35
|
+
logger.info("[#{tag}] #{message}")
|
36
|
+
end
|
37
|
+
|
38
|
+
def timing(message)
|
39
|
+
start_time = Time.now
|
40
|
+
result = yield
|
41
|
+
end_time = Time.now
|
42
|
+
|
43
|
+
info("#{message} (in #{format_duration(end_time - start_time)})")
|
44
|
+
|
45
|
+
result
|
46
|
+
end
|
47
|
+
|
48
|
+
def for_component(component)
|
49
|
+
self.class.for_component(component, logger: logger)
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def format_exception(exception)
|
55
|
+
frames = exception.backtrace.first(BACKTRACE_FRAMES).join("\n")
|
56
|
+
|
57
|
+
"#{exception.class}: #{exception}\n#{indent(frames)}"
|
58
|
+
end
|
59
|
+
|
60
|
+
def format_duration(duration)
|
61
|
+
duration_ms = duration * 1000
|
62
|
+
|
63
|
+
if duration_ms < 0.1
|
64
|
+
"less than 0.1ms"
|
65
|
+
else
|
66
|
+
"#{duration_ms.round(1)}ms"
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def indent(string)
|
71
|
+
string.indent(1, "\t")
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "nokogiri"
|
4
|
+
|
5
|
+
require "motion"
|
6
|
+
|
7
|
+
module Motion
|
8
|
+
class MarkupTransformer
|
9
|
+
STIMULUS_CONTROLLER_ATTRIBUTE = "data-controller"
|
10
|
+
|
11
|
+
attr_reader :serializer,
|
12
|
+
:stimulus_controller_identifier,
|
13
|
+
:key_attribute,
|
14
|
+
:state_attribute
|
15
|
+
|
16
|
+
def initialize(
|
17
|
+
serializer: Motion.serializer,
|
18
|
+
stimulus_controller_identifier:
|
19
|
+
Motion.config.stimulus_controller_identifier,
|
20
|
+
key_attribute: Motion.config.key_attribute,
|
21
|
+
state_attribute: Motion.config.state_attribute
|
22
|
+
)
|
23
|
+
@serializer = serializer
|
24
|
+
@stimulus_controller_identifier = stimulus_controller_identifier
|
25
|
+
@key_attribute = key_attribute
|
26
|
+
@state_attribute = state_attribute
|
27
|
+
end
|
28
|
+
|
29
|
+
def add_state_to_html(component, html)
|
30
|
+
key, state = serializer.serialize(component)
|
31
|
+
|
32
|
+
transform_root(component, html) do |root|
|
33
|
+
root[STIMULUS_CONTROLLER_ATTRIBUTE] =
|
34
|
+
values(
|
35
|
+
stimulus_controller_identifier,
|
36
|
+
root[STIMULUS_CONTROLLER_ATTRIBUTE]
|
37
|
+
)
|
38
|
+
|
39
|
+
root[key_attribute] = key
|
40
|
+
root[state_attribute] = state
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def transform_root(component, html)
|
47
|
+
fragment = Nokogiri::HTML::DocumentFragment.parse(html)
|
48
|
+
root, *unexpected_others = fragment.children
|
49
|
+
|
50
|
+
raise MultipleRootsError, component if unexpected_others.any?(&:present?)
|
51
|
+
|
52
|
+
yield root
|
53
|
+
|
54
|
+
fragment.to_html.html_safe
|
55
|
+
end
|
56
|
+
|
57
|
+
def values(*values, delimiter: " ")
|
58
|
+
values
|
59
|
+
.compact
|
60
|
+
.flat_map { |value| value.split(delimiter) }
|
61
|
+
.uniq
|
62
|
+
.join(delimiter)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "digest"
|
4
|
+
require "active_support/message_encryptor"
|
5
|
+
|
6
|
+
require "motion"
|
7
|
+
|
8
|
+
module Motion
|
9
|
+
class Serializer
|
10
|
+
HASH_PEPPER = "Motion"
|
11
|
+
private_constant :HASH_PEPPER
|
12
|
+
|
13
|
+
NULL_BYTE = "\0"
|
14
|
+
|
15
|
+
attr_reader :secret, :revision
|
16
|
+
|
17
|
+
def self.minimum_secret_byte_length
|
18
|
+
ActiveSupport::MessageEncryptor.key_len
|
19
|
+
end
|
20
|
+
|
21
|
+
def initialize(
|
22
|
+
secret: Motion.config.secret,
|
23
|
+
revision: Motion.config.revision
|
24
|
+
)
|
25
|
+
unless secret.each_byte.count >= self.class.minimum_secret_byte_length
|
26
|
+
raise BadSecretError.new(self.class.minimum_secret_byte_length)
|
27
|
+
end
|
28
|
+
|
29
|
+
raise BadRevisionError if revision.include?(NULL_BYTE)
|
30
|
+
|
31
|
+
@secret = secret
|
32
|
+
@revision = revision
|
33
|
+
end
|
34
|
+
|
35
|
+
def serialize(component)
|
36
|
+
state = dump(component)
|
37
|
+
state_with_revision = "#{revision}#{NULL_BYTE}#{state}"
|
38
|
+
|
39
|
+
[
|
40
|
+
salted_digest(state_with_revision),
|
41
|
+
encrypt_and_sign(state_with_revision)
|
42
|
+
]
|
43
|
+
end
|
44
|
+
|
45
|
+
def deserialize(serialized_component)
|
46
|
+
state_with_revision = decrypt_and_verify(serialized_component)
|
47
|
+
serialized_revision, state = state_with_revision.split(NULL_BYTE, 2)
|
48
|
+
component = load(state)
|
49
|
+
|
50
|
+
if revision == serialized_revision
|
51
|
+
component
|
52
|
+
else
|
53
|
+
component.class.upgrade_from(serialized_revision, component)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def dump(component)
|
60
|
+
Marshal.dump(component)
|
61
|
+
rescue TypeError => e
|
62
|
+
raise UnrepresentableStateError.new(component, e.message)
|
63
|
+
end
|
64
|
+
|
65
|
+
def load(state)
|
66
|
+
Marshal.load(state)
|
67
|
+
end
|
68
|
+
|
69
|
+
def encrypt_and_sign(cleartext)
|
70
|
+
encryptor.encrypt_and_sign(cleartext)
|
71
|
+
end
|
72
|
+
|
73
|
+
def decrypt_and_verify(cypertext)
|
74
|
+
encryptor.decrypt_and_verify(cypertext)
|
75
|
+
rescue ActiveSupport::MessageEncryptor::InvalidMessage,
|
76
|
+
ActiveSupport::MessageVerifier::InvalidSignature
|
77
|
+
raise InvalidSerializedStateError
|
78
|
+
end
|
79
|
+
|
80
|
+
def salted_digest(input)
|
81
|
+
Digest::SHA256.base64digest(hash_salt + input)
|
82
|
+
end
|
83
|
+
|
84
|
+
def encryptor
|
85
|
+
@encryptor ||= ActiveSupport::MessageEncryptor.new(derive_encryptor_key)
|
86
|
+
end
|
87
|
+
|
88
|
+
def hash_salt
|
89
|
+
@hash_salt ||= derive_hash_salt
|
90
|
+
end
|
91
|
+
|
92
|
+
def derive_encryptor_key
|
93
|
+
secret.byteslice(0, self.class.minimum_secret_byte_length)
|
94
|
+
end
|
95
|
+
|
96
|
+
def derive_hash_salt
|
97
|
+
Digest::SHA256.digest(HASH_PEPPER + secret)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "motion"
|
4
|
+
|
5
|
+
module Motion
|
6
|
+
module TestHelpers
|
7
|
+
def assert_motion(component, motion_name)
|
8
|
+
assert motion?(component, motion_name)
|
9
|
+
end
|
10
|
+
|
11
|
+
def refute_motion(component, motion_name)
|
12
|
+
refute motion?(component, motion_name)
|
13
|
+
end
|
14
|
+
|
15
|
+
def motion?(component, motion_name)
|
16
|
+
component.motions.include?(motion_name.to_s)
|
17
|
+
end
|
18
|
+
|
19
|
+
def run_motion(component, motion_name)
|
20
|
+
if block_given?
|
21
|
+
c = component.dup
|
22
|
+
c.process_motion(motion_name.to_s)
|
23
|
+
yield c
|
24
|
+
else
|
25
|
+
component.process_motion(motion_name.to_s)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
data/lib/motion.rb
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "motion/version"
|
4
|
+
require "motion/errors"
|
5
|
+
|
6
|
+
module Motion
|
7
|
+
autoload :ActionCableExtentions, "motion/action_cable_extentions"
|
8
|
+
autoload :Channel, "motion/channel"
|
9
|
+
autoload :Component, "motion/component"
|
10
|
+
autoload :ComponentConnection, "motion/component_connection"
|
11
|
+
autoload :Configuration, "motion/configuration"
|
12
|
+
autoload :Element, "motion/element"
|
13
|
+
autoload :Event, "motion/event"
|
14
|
+
autoload :LogHelper, "motion/log_helper"
|
15
|
+
autoload :MarkupTransformer, "motion/markup_transformer"
|
16
|
+
autoload :Railtie, "motion/railtie"
|
17
|
+
autoload :Serializer, "motion/serializer"
|
18
|
+
autoload :TestHelpers, "motion/test_helpers"
|
19
|
+
|
20
|
+
def self.configure(&block)
|
21
|
+
raise AlreadyConfiguredError if @config
|
22
|
+
|
23
|
+
@config = Configuration.new(&block)
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.config
|
27
|
+
@config ||= Configuration.default
|
28
|
+
end
|
29
|
+
|
30
|
+
singleton_class.alias_method :configuration, :config
|
31
|
+
|
32
|
+
def self.serializer
|
33
|
+
@serializer ||= Serializer.new
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.markup_transformer
|
37
|
+
@markup_transformer ||= MarkupTransformer.new
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.build_renderer_for(websocket_connection)
|
41
|
+
config.renderer_for_connection_proc.call(websocket_connection)
|
42
|
+
end
|
43
|
+
|
44
|
+
# This method only exists for testing. Changing configuration while Motion is
|
45
|
+
# in use is not supported. It is only safe to call this method when no
|
46
|
+
# components are currently mounted.
|
47
|
+
def self.reset_internal_state_for_testing!(new_configuration = nil)
|
48
|
+
@config = new_configuration
|
49
|
+
@serializer = nil
|
50
|
+
@markup_transformer = nil
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
require "motion/railtie" if defined?(Rails)
|