rsx-rb 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +34 -0
- data/LICENSE +21 -0
- data/README.md +1022 -0
- data/examples/components/button.rsx +22 -0
- data/examples/components/card.rsx +21 -0
- data/examples/components/sidebar.rsx +29 -0
- data/examples/components/theme.rsx +29 -0
- data/examples/components/user_table.rsx +64 -0
- data/examples/user_profile.rsx +51 -0
- data/examples/views/dashboard.html.rsx +26 -0
- data/exe/rsx +10 -0
- data/lib/rsx/attributes.rb +313 -0
- data/lib/rsx/cache.rb +102 -0
- data/lib/rsx/children.rb +78 -0
- data/lib/rsx/cli.rb +96 -0
- data/lib/rsx/codegen.rb +320 -0
- data/lib/rsx/compile_cache.rb +84 -0
- data/lib/rsx/component.rb +212 -0
- data/lib/rsx/context.rb +67 -0
- data/lib/rsx/errors.rb +27 -0
- data/lib/rsx/escape.rb +59 -0
- data/lib/rsx/helpers.rb +29 -0
- data/lib/rsx/loader.rb +194 -0
- data/lib/rsx/nodes.rb +26 -0
- data/lib/rsx/railtie.rb +81 -0
- data/lib/rsx/safe_string.rb +42 -0
- data/lib/rsx/tasks.rake +26 -0
- data/lib/rsx/template.rb +63 -0
- data/lib/rsx/template_handler.rb +35 -0
- data/lib/rsx/transformer.rb +1034 -0
- data/lib/rsx/version.rb +8 -0
- data/lib/rsx-rb.rb +4 -0
- data/lib/rsx.rb +430 -0
- metadata +86 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSX
|
|
4
|
+
# Base class for every `component` defined in a .rsx file.
|
|
5
|
+
#
|
|
6
|
+
# component Greeting do |name:|
|
|
7
|
+
# return <p>Hello {name}</p>
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# compiles to a subclass whose `rsx_render` is the block body. Components are
|
|
11
|
+
# plain Ruby objects: one instance per render, no inheritance requirements on
|
|
12
|
+
# the caller, and no framework needed to use them.
|
|
13
|
+
class Component
|
|
14
|
+
EMPTY_PROPS = {}.freeze
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
attr_accessor :rsx_source_path, :rsx_source_digest
|
|
18
|
+
attr_writer :rsx_cache_options
|
|
19
|
+
|
|
20
|
+
def rsx_cache_options
|
|
21
|
+
@rsx_cache_options
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Marks a component whose output never varies. The compiler sets this
|
|
25
|
+
# automatically when a component body is nothing but static markup.
|
|
26
|
+
def rsx_static!
|
|
27
|
+
@rsx_static = true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def rsx_static?
|
|
31
|
+
@rsx_static ? true : false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Enables whole-component caching.
|
|
35
|
+
#
|
|
36
|
+
# cache expires_in: 300
|
|
37
|
+
# cache key: ->(props) { [props[:user], props[:locale]] }
|
|
38
|
+
def cache(options = true)
|
|
39
|
+
@rsx_cache_options = RSX.normalize_cache_options(options)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Renders the component. This is the entry point used by compiled markup.
|
|
43
|
+
def rsx_call(props = nil, parent = nil)
|
|
44
|
+
return (@rsx_static_output ||= new(props, parent).rsx_perform) if rsx_static?
|
|
45
|
+
|
|
46
|
+
options = rsx_cache_options
|
|
47
|
+
return new(props, parent).rsx_perform unless options
|
|
48
|
+
|
|
49
|
+
key = rsx_cache_key(props, options)
|
|
50
|
+
cached = RSX.cache.fetch(key, expires_in: options[:expires_in]) do
|
|
51
|
+
new(props, parent).rsx_perform.to_s
|
|
52
|
+
end
|
|
53
|
+
cached.is_a?(SafeString) ? cached : SafeString.new(cached)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Public API: Button.call(label: "Save") => SafeString
|
|
57
|
+
def call(**props)
|
|
58
|
+
rsx_call(props)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def render(**props)
|
|
62
|
+
rsx_call(props)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def rsx_cache_key(props, options)
|
|
66
|
+
custom = options[:key]
|
|
67
|
+
payload =
|
|
68
|
+
if custom.nil?
|
|
69
|
+
(props || EMPTY_PROPS).reject { |key, _| key == :children }
|
|
70
|
+
elsif custom.respond_to?(:arity) && custom.arity.zero?
|
|
71
|
+
custom.call
|
|
72
|
+
else
|
|
73
|
+
custom.call(props || EMPTY_PROPS)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
"rsx/#{name || "component"}/#{rsx_source_digest || "0"}/#{RSX.stable_key(payload)}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def rsx_render_style
|
|
80
|
+
return @rsx_render_style if defined?(@rsx_render_style)
|
|
81
|
+
|
|
82
|
+
parameters = rsx_render_parameters
|
|
83
|
+
keyword = parameters.any? { |kind, _| %i[key keyreq keyrest].include?(kind) }
|
|
84
|
+
@rsx_render_style = keyword ? :keyword : :positional
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def rsx_render_parameters
|
|
88
|
+
instance_method(:rsx_render).parameters
|
|
89
|
+
rescue NameError
|
|
90
|
+
[]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The keyword props this component declares.
|
|
94
|
+
def rsx_accepted_props
|
|
95
|
+
@rsx_accepted_props ||= rsx_render_parameters.filter_map do |kind, name|
|
|
96
|
+
name if %i[key keyreq].include?(kind)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def rsx_accepts_extra_props?
|
|
101
|
+
return @rsx_accepts_extra_props if defined?(@rsx_accepts_extra_props)
|
|
102
|
+
|
|
103
|
+
@rsx_accepts_extra_props = rsx_render_parameters.any? { |kind, _| kind == :keyrest }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def rsx_filter_props(props)
|
|
107
|
+
return EMPTY_PROPS if props.nil? || props.empty?
|
|
108
|
+
return props if rsx_accepts_extra_props?
|
|
109
|
+
|
|
110
|
+
accepted = rsx_accepted_props
|
|
111
|
+
unknown = props.keys - accepted - [:children]
|
|
112
|
+
unless unknown.empty?
|
|
113
|
+
raise PropsError, "#{name} does not accept #{unknown.map(&:inspect).join(", ")}. " \
|
|
114
|
+
"Declared props: #{accepted.map(&:inspect).join(", ")}. " \
|
|
115
|
+
"Add `**rest` to the component parameters to accept anything else."
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
accepted.include?(:children) ? props : props.except(:children)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def inspect
|
|
122
|
+
name || super
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
attr_reader :props
|
|
127
|
+
|
|
128
|
+
def initialize(props = nil, parent = nil)
|
|
129
|
+
@props = props || EMPTY_PROPS
|
|
130
|
+
@rsx_parent = parent
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def rsx_perform
|
|
134
|
+
result =
|
|
135
|
+
if self.class.rsx_render_style == :keyword
|
|
136
|
+
rsx_render(**self.class.rsx_filter_props(@props))
|
|
137
|
+
else
|
|
138
|
+
rsx_render(@props)
|
|
139
|
+
end
|
|
140
|
+
RSX.child(result)
|
|
141
|
+
rescue ArgumentError => e
|
|
142
|
+
raise unless e.message.start_with?("missing keyword", "unknown keyword", "wrong number of arguments")
|
|
143
|
+
|
|
144
|
+
raise PropsError, "#{self.class.name}: #{e.message}. " \
|
|
145
|
+
"Declared props: #{self.class.rsx_accepted_props.map(&:inspect).join(", ")}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The markup nested inside this component's tag, rendered on demand.
|
|
149
|
+
def children
|
|
150
|
+
@props[:children]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def children?
|
|
154
|
+
child = children
|
|
155
|
+
!child.nil? && !(child.respond_to?(:empty?) && child.empty?)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The object that rendered this component: a view context in Rails, the
|
|
159
|
+
# parent component when nested, or nil when rendered directly.
|
|
160
|
+
def rsx_parent
|
|
161
|
+
@rsx_parent
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The nearest non-component render context, i.e. the Rails view. Gives access
|
|
165
|
+
# to url helpers, form builders, `t`, asset helpers and anything else the
|
|
166
|
+
# application exposes to templates.
|
|
167
|
+
def helpers
|
|
168
|
+
node = @rsx_parent
|
|
169
|
+
node = node.rsx_parent while node.is_a?(Component)
|
|
170
|
+
return node unless node.nil?
|
|
171
|
+
|
|
172
|
+
raise Error, "#{self.class.name} has no view context. Render it from a Rails view, " \
|
|
173
|
+
"or pass one with RSX.render(#{self.class.name}, context: view)."
|
|
174
|
+
end
|
|
175
|
+
alias view_context helpers
|
|
176
|
+
|
|
177
|
+
def helpers?
|
|
178
|
+
node = @rsx_parent
|
|
179
|
+
node = node.rsx_parent while node.is_a?(Component)
|
|
180
|
+
!node.nil?
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Reads a value provided by an enclosing <Ctx.Provider>.
|
|
184
|
+
def use_context(context)
|
|
185
|
+
context.value
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Caches a fragment of markup.
|
|
189
|
+
#
|
|
190
|
+
# {cache(["sidebar", user.id], expires_in: 300) do
|
|
191
|
+
# <nav>...</nav>
|
|
192
|
+
# end}
|
|
193
|
+
def cache(key, expires_in: nil)
|
|
194
|
+
full_key = "rsx/#{self.class.name}/#{self.class.rsx_source_digest || "0"}/#{RSX.stable_key(key)}"
|
|
195
|
+
cached = RSX.cache.fetch(full_key, expires_in: expires_in) { RSX.child(yield).to_s }
|
|
196
|
+
cached.is_a?(SafeString) ? cached : SafeString.new(cached)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Escape hatch for trusted HTML built elsewhere.
|
|
200
|
+
def raw(value)
|
|
201
|
+
RSX.raw(value)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def to_s
|
|
205
|
+
rsx_perform
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def inspect
|
|
209
|
+
"#<#{self.class.name} #{@props.inspect}>"
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
data/lib/rsx/context.rb
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSX
|
|
4
|
+
# React's Context API: a value provided high in the tree and read anywhere
|
|
5
|
+
# below it without threading props through every component in between.
|
|
6
|
+
#
|
|
7
|
+
# Theme = RSX.create_context("light")
|
|
8
|
+
#
|
|
9
|
+
# <Theme.Provider value={"dark"}>
|
|
10
|
+
# <Toolbar />
|
|
11
|
+
# </Theme.Provider>
|
|
12
|
+
#
|
|
13
|
+
# # inside any descendant
|
|
14
|
+
# {use_context(Theme)}
|
|
15
|
+
#
|
|
16
|
+
# Provided values live on a per-thread stack, so concurrent requests never see
|
|
17
|
+
# each other's context.
|
|
18
|
+
class Context
|
|
19
|
+
attr_reader :name, :default
|
|
20
|
+
|
|
21
|
+
def initialize(default = nil, name: nil)
|
|
22
|
+
@default = default
|
|
23
|
+
@name = name
|
|
24
|
+
@key = :"rsx_context_#{object_id}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def value
|
|
28
|
+
stack = Thread.current[@key]
|
|
29
|
+
stack && !stack.empty? ? stack.last : @default
|
|
30
|
+
end
|
|
31
|
+
alias current value
|
|
32
|
+
|
|
33
|
+
def with(value)
|
|
34
|
+
stack = (Thread.current[@key] ||= [])
|
|
35
|
+
stack.push(value)
|
|
36
|
+
yield
|
|
37
|
+
ensure
|
|
38
|
+
stack.pop
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Allows <Theme.Provider value={...}>
|
|
42
|
+
def Provider # rubocop:disable Naming/MethodName
|
|
43
|
+
@provider ||= ProviderComponent.new(self)
|
|
44
|
+
end
|
|
45
|
+
alias provider Provider
|
|
46
|
+
|
|
47
|
+
def inspect
|
|
48
|
+
"#<RSX::Context #{name || object_id} default=#{@default.inspect}>"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A component-like object: anything answering to rsx_call can be rendered.
|
|
52
|
+
class ProviderComponent
|
|
53
|
+
def initialize(context)
|
|
54
|
+
@context = context
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def name
|
|
58
|
+
"#{@context.name || "Context"}.Provider"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def rsx_call(props = nil, _parent = nil)
|
|
62
|
+
props ||= {}
|
|
63
|
+
@context.with(props[:value]) { RSX.child(props[:children]) }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/rsx/errors.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSX
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised when a .rsx file cannot be transformed into Ruby.
|
|
7
|
+
class SyntaxError < Error
|
|
8
|
+
attr_reader :path, :line, :column
|
|
9
|
+
|
|
10
|
+
def initialize(message, path: nil, line: nil, column: nil)
|
|
11
|
+
@path = path
|
|
12
|
+
@line = line
|
|
13
|
+
@column = column
|
|
14
|
+
location = [path || "(rsx)", line, column].compact.join(":")
|
|
15
|
+
super("#{location}: #{message}")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Raised when a <Tag /> cannot be resolved to a component.
|
|
20
|
+
class UnknownComponentError < Error; end
|
|
21
|
+
|
|
22
|
+
# Raised when a component is rendered with props it does not accept.
|
|
23
|
+
class PropsError < Error; end
|
|
24
|
+
|
|
25
|
+
# Raised when RSX cannot locate a .rsx file for a path or import.
|
|
26
|
+
class FileNotFoundError < Error; end
|
|
27
|
+
end
|
data/lib/rsx/escape.rb
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi/escape"
|
|
4
|
+
|
|
5
|
+
module RSX
|
|
6
|
+
# HTML escaping. Runtime escaping goes through CGI.escapeHTML (a C extension
|
|
7
|
+
# in stdlib) so no third-party gem is needed.
|
|
8
|
+
module Escape
|
|
9
|
+
# Matches a character reference such as & © or —
|
|
10
|
+
ENTITY = /&(?:[a-zA-Z][a-zA-Z0-9]{1,30}|#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6});/
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Escapes text for element content or an attribute value.
|
|
15
|
+
def html(string)
|
|
16
|
+
CGI.escapeHTML(string)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# True for values that must not be escaped again.
|
|
20
|
+
#
|
|
21
|
+
# ActiveSupport::SafeBuffer (what Rails helpers return) is a String
|
|
22
|
+
# subclass, so the exact-class check below is what keeps `link_to` output
|
|
23
|
+
# from being escaped while still taking the fast path for plain strings.
|
|
24
|
+
def safe?(value)
|
|
25
|
+
return true if value.is_a?(SafeString)
|
|
26
|
+
return false if value.instance_of?(String)
|
|
27
|
+
|
|
28
|
+
value.respond_to?(:html_safe?) && value.html_safe?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Escaping for *static* text baked in at compile time.
|
|
32
|
+
#
|
|
33
|
+
# JSX passes character references such as through to the browser, so
|
|
34
|
+
# RSX keeps well-formed entities intact while still escaping stray markup.
|
|
35
|
+
def static_text(string)
|
|
36
|
+
return CGI.escapeHTML(string) unless string.include?("&")
|
|
37
|
+
|
|
38
|
+
out = +""
|
|
39
|
+
last = 0
|
|
40
|
+
string.scan(ENTITY) do
|
|
41
|
+
match = Regexp.last_match
|
|
42
|
+
out << CGI.escapeHTML(string[last...match.begin(0)])
|
|
43
|
+
out << match[0]
|
|
44
|
+
last = match.end(0)
|
|
45
|
+
end
|
|
46
|
+
out << CGI.escapeHTML(string[last..]) if last < string.length
|
|
47
|
+
out
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Escapes a value destined for a double-quoted attribute.
|
|
51
|
+
def attribute(value)
|
|
52
|
+
return value if value.is_a?(SafeString)
|
|
53
|
+
return CGI.escapeHTML(value) if value.instance_of?(String)
|
|
54
|
+
return value.to_s if safe?(value)
|
|
55
|
+
|
|
56
|
+
CGI.escapeHTML(value.to_s)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/rsx/helpers.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSX
|
|
4
|
+
# Mixed into ActionView so components can be rendered from ERB, Haml, Slim or
|
|
5
|
+
# anywhere else a view helper is available.
|
|
6
|
+
#
|
|
7
|
+
# <%= rsx UserProfile, user: @user %>
|
|
8
|
+
# <%= rsx "Card", title: "Hello" do %>
|
|
9
|
+
# <p>Body rendered by ERB, passed to the component as children.</p>
|
|
10
|
+
# <% end %>
|
|
11
|
+
module Helpers
|
|
12
|
+
def rsx(target, **props, &block)
|
|
13
|
+
children =
|
|
14
|
+
if block
|
|
15
|
+
captured = capture(&block)
|
|
16
|
+
RSX::Children.new { RSX.raw(captured) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
component = target.is_a?(String) || target.is_a?(Symbol) ? RSX.lookup_component(target) : target
|
|
20
|
+
RSX.safe(RSX.render_component(component, props.empty? ? nil : props, children, self))
|
|
21
|
+
end
|
|
22
|
+
alias render_rsx rsx
|
|
23
|
+
|
|
24
|
+
# Renders a .rsx file directly by path, relative to the configured paths.
|
|
25
|
+
def rsx_file(path, **props)
|
|
26
|
+
RSX.render_file(path, context: self, **props)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/rsx/loader.rb
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module RSX
|
|
6
|
+
# Finds, compiles, evaluates and reloads .rsx files.
|
|
7
|
+
class Loader
|
|
8
|
+
EXTENSIONS = [".rsx", ".html.rsx"].freeze
|
|
9
|
+
|
|
10
|
+
Entry = Struct.new(:path, :digest, :mtime, :components, :default, :template, keyword_init: true) do
|
|
11
|
+
# Markup files render through a template; component files render their
|
|
12
|
+
# default export.
|
|
13
|
+
def renderable
|
|
14
|
+
default || components.first
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(config)
|
|
19
|
+
@config = config
|
|
20
|
+
@entries = {}
|
|
21
|
+
@stack = []
|
|
22
|
+
@monitor = Monitor.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def entries
|
|
26
|
+
@entries.values
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def loaded?(path)
|
|
30
|
+
@entries.key?(File.expand_path(path))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The file currently being evaluated, used to attribute components to it.
|
|
34
|
+
def current_entry
|
|
35
|
+
@stack.last
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def compile_cache
|
|
39
|
+
@config.compile_cache
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Compiles and evaluates a .rsx file exactly once per digest.
|
|
43
|
+
def load(path, force: false)
|
|
44
|
+
absolute = File.expand_path(path)
|
|
45
|
+
raise FileNotFoundError, "no such RSX file: #{absolute}" unless File.file?(absolute)
|
|
46
|
+
|
|
47
|
+
@monitor.synchronize do
|
|
48
|
+
source = File.read(absolute)
|
|
49
|
+
digest = compile_cache.digest(source)
|
|
50
|
+
existing = @entries[absolute]
|
|
51
|
+
return existing if existing && existing.digest == digest && !force
|
|
52
|
+
|
|
53
|
+
unload(existing) if existing
|
|
54
|
+
|
|
55
|
+
entry = Entry.new(path: absolute, digest: digest, mtime: mtime(absolute), components: [], default: nil)
|
|
56
|
+
@entries[absolute] = entry
|
|
57
|
+
@stack.push(entry)
|
|
58
|
+
|
|
59
|
+
begin
|
|
60
|
+
ruby = compile_cache.fetch(absolute, source) do
|
|
61
|
+
Transformer.transform(source, path: absolute)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if Transformer.defines_components?(ruby)
|
|
65
|
+
# Component files are evaluated at the top level so that `class`,
|
|
66
|
+
# `def` and constants behave exactly as they do in a .rb file.
|
|
67
|
+
eval(ruby, TOPLEVEL_BINDING, absolute, 1) # rubocop:disable Security/Eval
|
|
68
|
+
else
|
|
69
|
+
entry.template = Template.from_ruby(ruby, path: absolute, digest: digest)
|
|
70
|
+
end
|
|
71
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
72
|
+
@entries.delete(absolute)
|
|
73
|
+
raise
|
|
74
|
+
ensure
|
|
75
|
+
@stack.pop
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
entry
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Loads every .rsx file under the configured paths. Called at boot in
|
|
83
|
+
# production so no request pays for compilation.
|
|
84
|
+
def load_all(paths = @config.paths)
|
|
85
|
+
files(paths).each { |file| load(file) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def files(paths = @config.paths)
|
|
89
|
+
Array(paths).flat_map do |root|
|
|
90
|
+
next [] unless File.directory?(root)
|
|
91
|
+
|
|
92
|
+
Dir.glob(File.join(root, "**", "*.rsx")).sort
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Reloads only the files whose contents changed. Used by the Rails reloader.
|
|
97
|
+
def reload!
|
|
98
|
+
@monitor.synchronize do
|
|
99
|
+
@entries.values.each do |entry|
|
|
100
|
+
if !File.file?(entry.path)
|
|
101
|
+
unload(entry)
|
|
102
|
+
@entries.delete(entry.path)
|
|
103
|
+
elsif mtime(entry.path) != entry.mtime
|
|
104
|
+
load(entry.path, force: true)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def clear
|
|
111
|
+
@monitor.synchronize do
|
|
112
|
+
@entries.each_value { |entry| unload(entry) }
|
|
113
|
+
@entries.clear
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Resolves an import specifier or template path to a file on disk.
|
|
118
|
+
def resolve(spec, from: nil)
|
|
119
|
+
spec = spec.to_s
|
|
120
|
+
candidates = []
|
|
121
|
+
|
|
122
|
+
if spec.start_with?("/")
|
|
123
|
+
candidates << spec
|
|
124
|
+
else
|
|
125
|
+
candidates << File.expand_path(spec, File.dirname(from)) if from && !from.empty?
|
|
126
|
+
Array(@config.paths).each { |root| candidates << File.join(root, spec) }
|
|
127
|
+
candidates << File.expand_path(spec, Dir.pwd)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
candidates.each do |candidate|
|
|
131
|
+
return candidate if File.file?(candidate)
|
|
132
|
+
|
|
133
|
+
EXTENSIONS.each do |extension|
|
|
134
|
+
with_extension = "#{candidate}#{extension}"
|
|
135
|
+
return with_extension if File.file?(with_extension)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def resolve!(spec, from: nil)
|
|
143
|
+
resolve(spec, from: from) ||
|
|
144
|
+
raise(FileNotFoundError, "could not find `#{spec}`#{" imported from #{from}" if from}. " \
|
|
145
|
+
"Looked in: #{Array(@config.paths).join(", ")}")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def import(spec, as: nil, from: nil)
|
|
149
|
+
entry = load(resolve!(spec, from: from))
|
|
150
|
+
Array(as).each { |name| alias_constant(name, entry) }
|
|
151
|
+
entry.default || entry.components.first
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Records a component defined by the file currently being loaded.
|
|
155
|
+
def track(component)
|
|
156
|
+
entry = current_entry
|
|
157
|
+
return component unless entry
|
|
158
|
+
|
|
159
|
+
entry.components << component
|
|
160
|
+
entry.default ||= component
|
|
161
|
+
component.rsx_source_path = entry.path
|
|
162
|
+
component.rsx_source_digest = entry.digest
|
|
163
|
+
component
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def default_export(path)
|
|
167
|
+
entry = load(path)
|
|
168
|
+
entry.default || entry.components.first
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def mtime(path)
|
|
174
|
+
File.mtime(path)
|
|
175
|
+
rescue SystemCallError
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def alias_constant(name, entry)
|
|
180
|
+
name = name.to_s
|
|
181
|
+
target = entry.default || entry.components.first
|
|
182
|
+
return if target.nil?
|
|
183
|
+
return if RSX.const_defined_at?(name)
|
|
184
|
+
|
|
185
|
+
RSX.assign_constant(name, target)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def unload(entry)
|
|
189
|
+
entry.components.each { |component| RSX.remove_constant(component) }
|
|
190
|
+
entry.components.clear
|
|
191
|
+
entry.default = nil
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
data/lib/rsx/nodes.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSX
|
|
4
|
+
# The RSX syntax tree. Every node records the source line it started on so the
|
|
5
|
+
# generated Ruby can be padded to keep backtraces aligned with the .rsx file.
|
|
6
|
+
module Nodes
|
|
7
|
+
# Literal markup text, already whitespace-normalized JSX style.
|
|
8
|
+
Text = Struct.new(:value, :line)
|
|
9
|
+
|
|
10
|
+
# {ruby} in child position.
|
|
11
|
+
Expression = Struct.new(:source, :line)
|
|
12
|
+
|
|
13
|
+
# <div>, <img />, <my-element>
|
|
14
|
+
Element = Struct.new(:tag, :attributes, :children, :self_closing, :line)
|
|
15
|
+
|
|
16
|
+
# <Button>, <Admin::Card>, <Theme.Provider>
|
|
17
|
+
Component = Struct.new(:name, :attributes, :children, :line)
|
|
18
|
+
|
|
19
|
+
# <>...</>
|
|
20
|
+
Fragment = Struct.new(:children, :line)
|
|
21
|
+
|
|
22
|
+
# kind is :static (name="text"), :expression (name={ruby}),
|
|
23
|
+
# :boolean (bare name) or :spread ({**ruby} / {...ruby}).
|
|
24
|
+
Attribute = Struct.new(:name, :value, :kind, :line)
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/rsx/railtie.rb
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "template_handler"
|
|
4
|
+
require_relative "helpers"
|
|
5
|
+
|
|
6
|
+
module RSX
|
|
7
|
+
# Wires RSX into a Rails application. Nothing here runs unless Rails is
|
|
8
|
+
# already loaded, which keeps the gem usable outside Rails.
|
|
9
|
+
#
|
|
10
|
+
# Configure it from config/application.rb:
|
|
11
|
+
#
|
|
12
|
+
# config.rsx.paths = [Rails.root.join("app/components")]
|
|
13
|
+
# config.rsx.cache_store = :rails
|
|
14
|
+
#
|
|
15
|
+
class Railtie < ::Rails::Railtie
|
|
16
|
+
config.rsx = ActiveSupport::OrderedOptions.new
|
|
17
|
+
|
|
18
|
+
initializer "rsx.configure" do |app|
|
|
19
|
+
options = app.config.rsx
|
|
20
|
+
|
|
21
|
+
RSX.configure do |config|
|
|
22
|
+
config.paths = Array(options.paths.presence || default_paths(app))
|
|
23
|
+
config.cache_dir = options.key?(:cache_dir) ? options.cache_dir : app.root.join("tmp/cache/rsx").to_s
|
|
24
|
+
config.component_namespace = options.component_namespace || Object
|
|
25
|
+
config.reload = options.key?(:reload) ? options.reload : reloading?(app)
|
|
26
|
+
config.cache_store = build_cache_store(options.cache_store) if options.cache_store
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
initializer "rsx.action_view" do
|
|
31
|
+
ActiveSupport.on_load(:action_view) do
|
|
32
|
+
ActionView::Template.register_template_handler(:rsx, RSX::TemplateHandler)
|
|
33
|
+
include RSX::Helpers
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
initializer "rsx.load_components" do |app|
|
|
38
|
+
# Eager load in production so no request ever pays for compilation, and
|
|
39
|
+
# reload changed files between requests in development.
|
|
40
|
+
if app.config.eager_load
|
|
41
|
+
app.config.after_initialize { RSX.load_all }
|
|
42
|
+
else
|
|
43
|
+
app.reloader.to_prepare { RSX.reload! if RSX.config.reload }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
app.config.watchable_dirs ||= {}
|
|
47
|
+
Array(RSX.config.paths).each do |path|
|
|
48
|
+
app.config.watchable_dirs[path.to_s] = [:rsx] if File.directory?(path.to_s)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
rake_tasks do
|
|
53
|
+
load File.expand_path("tasks.rake", __dir__)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Initializer blocks are instance_exec'd on the railtie instance, so these
|
|
57
|
+
# helpers have to be instance methods.
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def default_paths(app)
|
|
61
|
+
%w[app/components app/rsx].map { |path| app.root.join(path).to_s }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def reloading?(app)
|
|
65
|
+
if app.config.respond_to?(:enable_reloading)
|
|
66
|
+
app.config.enable_reloading
|
|
67
|
+
else
|
|
68
|
+
!app.config.cache_classes
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build_cache_store(setting)
|
|
73
|
+
case setting
|
|
74
|
+
when :rails, "rails" then Cache::Rails.new
|
|
75
|
+
when :memory, "memory" then Cache::Memory.new
|
|
76
|
+
when :null, "null", false then Cache::Null.new
|
|
77
|
+
else setting
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|