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.
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSX
4
+ # The `children` prop of a component element.
5
+ #
6
+ # Children compile to a block rather than a finished string, so they are
7
+ # rendered *inside* the component that received them. That is what lets
8
+ # <Theme.Provider> change what its children see, lets a caching component skip
9
+ # building them at all, and means a component that never renders {children}
10
+ # never pays for them.
11
+ #
12
+ # The block's value is memoized, and kept in its raw form as well, so React's
13
+ # render-prop pattern works: <Consumer>{->(value) { ... }}</Consumer>.
14
+ class Children
15
+ UNSET = Object.new
16
+ private_constant :UNSET
17
+
18
+ def initialize(&block)
19
+ @block = block
20
+ @value = UNSET
21
+ end
22
+
23
+ # The value the children expression evaluated to, before coercion.
24
+ def value
25
+ @value = @block.call if @value.equal?(UNSET)
26
+ @value
27
+ end
28
+
29
+ # The rendered markup. Used whenever children are interpolated as {children}.
30
+ def render
31
+ @render ||= RSX.child(value)
32
+ end
33
+ alias to_rsx render
34
+ alias to_safe_string render
35
+
36
+ def to_s
37
+ render.to_s
38
+ end
39
+
40
+ def to_str
41
+ render.to_s
42
+ end
43
+
44
+ def html_safe?
45
+ true
46
+ end
47
+
48
+ # Render props: {children.call(item)} passes a value back to the caller.
49
+ def call(*arguments, **options, &block)
50
+ raw = value
51
+ return raw.call(*arguments, **options, &block) if raw.respond_to?(:call)
52
+
53
+ raw
54
+ end
55
+
56
+ def empty?
57
+ render.empty?
58
+ end
59
+ alias blank? empty?
60
+
61
+ def present?
62
+ !empty?
63
+ end
64
+ alias any? present?
65
+
66
+ def length
67
+ render.length
68
+ end
69
+
70
+ def ==(other)
71
+ to_s == other.to_s
72
+ end
73
+
74
+ def inspect
75
+ "#<RSX::Children #{@render ? @render.inspect : "(not rendered)"}>"
76
+ end
77
+ end
78
+ end
data/lib/rsx/cli.rb ADDED
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module RSX
6
+ # `rsx` command line tool. Useful for seeing exactly what a template compiles
7
+ # to, rendering a file outside of Rails, and warming the compile cache.
8
+ class CLI
9
+ BANNER = <<~TEXT
10
+ Usage: rsx COMMAND [options] [files]
11
+
12
+ Commands:
13
+ compile FILE... Print the Ruby a .rsx file compiles to
14
+ render FILE Render a .rsx file and print the HTML
15
+ precompile DIR... Compile every .rsx file under DIR and cache the result
16
+ version Print the RSX version
17
+
18
+ Options:
19
+ TEXT
20
+
21
+ def self.start(argv)
22
+ new.run(argv)
23
+ end
24
+
25
+ def run(argv)
26
+ options = { props: {}, cache_dir: nil }
27
+
28
+ parser = OptionParser.new do |opts|
29
+ opts.banner = BANNER
30
+ opts.on("-p", "--prop NAME=VALUE", "Pass a string prop when rendering") do |pair|
31
+ name, value = pair.split("=", 2)
32
+ options[:props][name.to_sym] = value
33
+ end
34
+ opts.on("-I", "--include PATH", "Add a directory to the RSX load path") do |path|
35
+ RSX.config.paths |= [File.expand_path(path)]
36
+ end
37
+ opts.on("-c", "--cache-dir DIR", "Directory for compiled output") do |dir|
38
+ options[:cache_dir] = dir
39
+ end
40
+ opts.on("-h", "--help", "Show this message") { puts opts; return 0 }
41
+ end
42
+
43
+ arguments = parser.parse(argv)
44
+ command = arguments.shift
45
+
46
+ RSX.config.cache_dir = options[:cache_dir]
47
+
48
+ case command
49
+ when "compile" then compile(arguments)
50
+ when "render" then render(arguments, options[:props])
51
+ when "precompile" then precompile(arguments, options[:cache_dir])
52
+ when "version", "-v", "--version" then puts RSX::VERSION
53
+ when nil then puts parser
54
+ else
55
+ warn "rsx: unknown command #{command.inspect}"
56
+ puts parser
57
+ return 1
58
+ end
59
+
60
+ 0
61
+ rescue RSX::Error, Errno::ENOENT => e
62
+ warn "rsx: #{e.message}"
63
+ 1
64
+ end
65
+
66
+ private
67
+
68
+ def compile(files)
69
+ abort_missing(files)
70
+ files.each do |file|
71
+ puts "# #{file}" if files.length > 1
72
+ puts RSX.compile(File.read(file), path: File.expand_path(file))
73
+ end
74
+ end
75
+
76
+ def render(files, props)
77
+ abort_missing(files)
78
+ files.each { |file| puts RSX.render_file(File.expand_path(file), **props) }
79
+ end
80
+
81
+ def precompile(directories, cache_dir)
82
+ directories = [Dir.pwd] if directories.empty?
83
+ RSX.config.cache_dir = cache_dir || File.join(Dir.pwd, "tmp", "cache", "rsx")
84
+ files = RSX.loader.files(directories)
85
+ RSX.precompile!(directories)
86
+ puts "rsx: precompiled #{files.length} file(s) into #{RSX.config.cache_dir}"
87
+ end
88
+
89
+ def abort_missing(files)
90
+ raise Error, "no files given" if files.empty?
91
+
92
+ missing = files.reject { |file| File.file?(file) }
93
+ raise Error, "no such file: #{missing.join(", ")}" unless missing.empty?
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSX
4
+ # Turns an RSX node tree into Ruby source.
5
+ #
6
+ # Markup compiles to a single interpolated string literal: static text is baked
7
+ # in at compile time and only the {ruby} parts remain as interpolations. Nested
8
+ # elements are spliced into their parent's literal, so an entire static subtree
9
+ # collapses to one frozen string with no intermediate objects.
10
+ #
11
+ # <ul className="list"><li>{name}</li></ul>
12
+ #
13
+ # becomes
14
+ #
15
+ # ::RSX::SafeString.new("<ul class=\"list\"><li>#{::RSX.child(name)}</li></ul>")
16
+ class Codegen
17
+ LITERAL_ESCAPES = {
18
+ "\\" => "\\\\",
19
+ '"' => '\"',
20
+ "\n" => '\n',
21
+ "\t" => '\t',
22
+ "\r" => '\r',
23
+ "\e" => '\e',
24
+ "\0" => '\0'
25
+ }.freeze
26
+
27
+ LITERAL_PATTERN = /[\\"\n\t\r\e\0]|#(?=[{$@])/
28
+
29
+ INNER_HTML = %w[dangerouslySetInnerHTML dangerously_set_inner_html].freeze
30
+
31
+ def initialize(path: nil, prefix: nil)
32
+ @path = path
33
+ @prefix = prefix || "rsx"
34
+ @statics = 0
35
+ end
36
+
37
+ # Returns [ruby_source, static?]
38
+ def compile(node, start_line:, end_line:)
39
+ parts = []
40
+ emit(node, parts)
41
+ parts = merge(parts)
42
+ static = parts.all? { |part| part[0] == :static }
43
+ [assemble(parts, start_line, end_line, static), static]
44
+ end
45
+
46
+ private
47
+
48
+ def emit(node, parts)
49
+ case node
50
+ when Nodes::Text
51
+ parts << [:static, Escape.static_text(node.value), node.line]
52
+ when Nodes::Expression
53
+ # The extra parentheses let a container hold anything Ruby accepts as an
54
+ # expression, including modifiers: {greeting if signed_in?}.
55
+ parts << [:dynamic, "::RSX.child((#{node.source}))", node.line]
56
+ when Nodes::Fragment
57
+ node.children.each { |child| emit(child, parts) }
58
+ when Nodes::Element
59
+ emit_element(node, parts)
60
+ when Nodes::Component
61
+ emit_component(node, parts)
62
+ else
63
+ raise Error, "unexpected node #{node.inspect}"
64
+ end
65
+ end
66
+
67
+ # ------------------------------------------------------------------
68
+ # Elements
69
+ # ------------------------------------------------------------------
70
+
71
+ def emit_element(node, parts)
72
+ tag = node.tag
73
+ parts << [:static, "<#{tag}", node.line]
74
+
75
+ inner_html = nil
76
+ attributes = node.attributes.reject do |attribute|
77
+ next false unless attribute.kind != :spread && INNER_HTML.include?(attribute.name)
78
+
79
+ inner_html = attribute
80
+ true
81
+ end
82
+
83
+ if attributes.any? { |attribute| attribute.kind == :spread }
84
+ emit_spread_attributes(attributes, parts)
85
+ else
86
+ attributes.each { |attribute| emit_attribute(attribute, parts) }
87
+ end
88
+
89
+ if inner_html
90
+ parts << [:static, ">", last_line(parts, node)]
91
+ parts << [:dynamic, "::RSX.raw_html(#{inner_html.value})", inner_html.line]
92
+ parts << [:static, "</#{tag}>", last_line(parts, node)]
93
+ return
94
+ end
95
+
96
+ if Attributes.void?(tag)
97
+ parts << [:static, ">", last_line(parts, node)]
98
+ return
99
+ end
100
+
101
+ if node.children.empty?
102
+ if node.self_closing && Attributes.self_closing?(tag)
103
+ parts << [:static, "/>", last_line(parts, node)]
104
+ else
105
+ parts << [:static, "></#{tag}>", last_line(parts, node)]
106
+ end
107
+ return
108
+ end
109
+
110
+ parts << [:static, ">", last_line(parts, node)]
111
+ node.children.each { |child| emit(child, parts) }
112
+ parts << [:static, "</#{tag}>", last_line(parts, node)]
113
+ end
114
+
115
+ # An element with a spread has all of its attributes merged at runtime, so a
116
+ # later value replaces an earlier one exactly as it would in React. Emitting
117
+ # them one by one would instead produce a duplicate HTML attribute, where
118
+ # the browser keeps the first.
119
+ def emit_spread_attributes(attributes, parts)
120
+ groups = []
121
+ attributes.each do |attribute|
122
+ if attribute.kind == :spread
123
+ groups << attribute.value
124
+ elsif groups.last.is_a?(Array)
125
+ groups.last << attribute_pair(attribute)
126
+ else
127
+ groups << [attribute_pair(attribute)]
128
+ end
129
+ end
130
+
131
+ source =
132
+ if groups.length == 1 && !groups.first.is_a?(Array)
133
+ "::RSX::Attributes.render_all(#{groups.first})"
134
+ else
135
+ arguments = groups.map { |group| group.is_a?(Array) ? "{ #{group.join(", ")} }" : "(#{group})" }
136
+ "::RSX::Attributes.render_all(::RSX::Attributes.merge(#{arguments.join(", ")}))"
137
+ end
138
+
139
+ parts << [:dynamic, source, attributes.first.line]
140
+ end
141
+
142
+ def attribute_pair(attribute)
143
+ key = symbol_literal(attribute.name)
144
+
145
+ case attribute.kind
146
+ when :boolean then "#{key} => true"
147
+ when :static then %(#{key} => "#{escape_literal(attribute.value)}")
148
+ when :expression then "#{key} => (#{attribute.value})"
149
+ end
150
+ end
151
+
152
+ def emit_attribute(attribute, parts)
153
+ name = Attributes.attribute_name(attribute.name)
154
+ return if name.nil? || name.empty?
155
+
156
+ case attribute.kind
157
+ when :boolean
158
+ text = Attributes.boolean?(name) ? " #{name}" : %( #{name}="true")
159
+ parts << [:static, text, attribute.line]
160
+ when :static
161
+ parts << [:static, static_attribute(name, attribute.value), attribute.line]
162
+ when :expression
163
+ parts << [:dynamic, dynamic_attribute(name, attribute.value), attribute.line]
164
+ end
165
+ end
166
+
167
+ def static_attribute(name, value)
168
+ %( #{name}="#{Escape.static_text(value)}")
169
+ end
170
+
171
+ def dynamic_attribute(name, source)
172
+ case name
173
+ when "class" then "::RSX::Attributes.render_class(#{source})"
174
+ when "style" then "::RSX::Attributes.render_style(#{source})"
175
+ when "data", "aria" then %(::RSX::Attributes.render_nested("#{name}", #{source}))
176
+ else %(::RSX::Attributes.render("#{name}", #{source}))
177
+ end
178
+ end
179
+
180
+ # ------------------------------------------------------------------
181
+ # Components
182
+ # ------------------------------------------------------------------
183
+
184
+ def emit_component(node, parts)
185
+ source = "::RSX.render_component(#{node.name}, #{props_source(node)}, #{children_source(node)}, self)"
186
+ parts << [:dynamic, source, node.line]
187
+ end
188
+
189
+ def props_source(node)
190
+ entries = node.attributes.map do |attribute|
191
+ case attribute.kind
192
+ when :spread then "**(#{attribute.value})"
193
+ when :boolean then "#{symbol_literal(attribute.name)} => true"
194
+ when :static then "#{symbol_literal(attribute.name)} => \"#{escape_literal(attribute.value)}\""
195
+ when :expression then "#{symbol_literal(attribute.name)} => (#{attribute.value})"
196
+ end
197
+ end
198
+
199
+ entries.empty? ? "nil" : "{ #{entries.join(", ")} }"
200
+ end
201
+
202
+ def symbol_literal(name)
203
+ name.match?(/\A[A-Za-z_][A-Za-z0-9_]*[?!]?\z/) ? ":#{name}" : %(:"#{escape_literal(name)}")
204
+ end
205
+
206
+ # Children are passed lazily so that components can decide whether (and in
207
+ # what context) to render them: caching, context providers and conditional
208
+ # slots all depend on not having rendered them yet. Fully static children
209
+ # skip the wrapper entirely since there is nothing to defer.
210
+ def children_source(node)
211
+ return "nil" if node.children.empty?
212
+
213
+ # A lone {expression} is handed over untouched rather than rendered to a
214
+ # string, so children can be any value: a lambda for a render prop, an
215
+ # array, a model. RSX.child coerces it if the component interpolates it.
216
+ if node.children.length == 1 && node.children.first.is_a?(Nodes::Expression)
217
+ return "::RSX::Children.new { #{node.children.first.source} }"
218
+ end
219
+
220
+ parts = []
221
+ node.children.each { |child| emit(child, parts) }
222
+ parts = merge(parts)
223
+ return "nil" if parts.empty?
224
+
225
+ static = parts.all? { |part| part[0] == :static }
226
+ body = assemble(parts, parts.first[2], parts.last[2], static)
227
+ static ? body : "::RSX::Children.new { #{body} }"
228
+ end
229
+
230
+ # ------------------------------------------------------------------
231
+ # Assembly
232
+ # ------------------------------------------------------------------
233
+
234
+ def merge(parts)
235
+ merged = []
236
+ parts.each do |part|
237
+ previous = merged.last
238
+ if part[0] == :static && previous && previous[0] == :static
239
+ previous[1] += part[1]
240
+ else
241
+ merged << part.dup
242
+ end
243
+ end
244
+ merged
245
+ end
246
+
247
+ def assemble(parts, start_line, end_line, static)
248
+ buffer = +""
249
+
250
+ # Markup with nothing dynamic in it is built once and then reused from a
251
+ # per-call-site slot, so re-rendering it allocates nothing at all. The
252
+ # literal after `||=` is never evaluated again after the first render.
253
+ buffer << "(::RSX::STATICS[#{static_slot}] ||= " if static
254
+ buffer << (static ? "::RSX.static(" : "::RSX::SafeString.new(")
255
+ state = { line: start_line, open: false }
256
+
257
+ parts.each do |kind, text, line|
258
+ pad(buffer, state, line)
259
+ open_literal(buffer, state)
260
+
261
+ if kind == :static
262
+ buffer << escape_literal(text)
263
+ else
264
+ buffer << '#{' << text << "}"
265
+ state[:line] += text.count("\n")
266
+ end
267
+ end
268
+
269
+ open_literal(buffer, state) if parts.empty?
270
+ close_literal(buffer, state)
271
+ pad(buffer, state, end_line)
272
+ buffer << ")"
273
+ buffer << ")" if static
274
+ buffer
275
+ end
276
+
277
+ def static_slot
278
+ @statics += 1
279
+ %(:"#{@prefix}#{@statics}")
280
+ end
281
+
282
+ # Keeps generated Ruby on the same lines as the .rsx source it came from.
283
+ # Inside a string literal, extra lines are added by closing the literal and
284
+ # continuing it: adjacent literals are concatenated by the parser, so no
285
+ # markup is affected.
286
+ def pad(buffer, state, target)
287
+ count = target - state[:line]
288
+ return if count <= 0
289
+
290
+ if state[:open]
291
+ buffer << '" ' << ("\\\n" * count) << '"'
292
+ else
293
+ buffer << ("\n" * count)
294
+ end
295
+ state[:line] = target
296
+ end
297
+
298
+ def open_literal(buffer, state)
299
+ return if state[:open]
300
+
301
+ buffer << '"'
302
+ state[:open] = true
303
+ end
304
+
305
+ def close_literal(buffer, state)
306
+ return unless state[:open]
307
+
308
+ buffer << '"'
309
+ state[:open] = false
310
+ end
311
+
312
+ def escape_literal(text)
313
+ text.gsub(LITERAL_PATTERN) { |match| LITERAL_ESCAPES[match] || "\\#{match}" }
314
+ end
315
+
316
+ def last_line(parts, node)
317
+ parts.empty? ? node.line : parts.last[2]
318
+ end
319
+ end
320
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+
6
+ module RSX
7
+ # Caches the Ruby produced from .rsx files on disk, keyed by a digest of the
8
+ # source. A warm cache turns loading a template into reading a .rb file, so
9
+ # booting a large application does no transformation work at all.
10
+ class CompileCache
11
+ attr_reader :directory
12
+
13
+ def initialize(directory)
14
+ @directory = directory
15
+ @enabled = !directory.nil?
16
+ @memory = {}
17
+ @lock = Mutex.new
18
+ end
19
+
20
+ def enabled?
21
+ @enabled
22
+ end
23
+
24
+ def digest(source)
25
+ Digest::SHA256.hexdigest("#{COMPILER_VERSION}\0#{source}")[0, 32]
26
+ end
27
+
28
+ # Returns the compiled Ruby for source, compiling only on a cache miss.
29
+ def fetch(path, source)
30
+ key = digest(source)
31
+ cached = @lock.synchronize { @memory[key] }
32
+ return cached if cached
33
+
34
+ ruby = read(path, key) || begin
35
+ compiled = yield
36
+ write(path, key, compiled)
37
+ compiled
38
+ end
39
+
40
+ @lock.synchronize { @memory[key] = ruby }
41
+ ruby
42
+ end
43
+
44
+ def path_for(source_path, key)
45
+ return nil unless enabled?
46
+
47
+ basename = File.basename(source_path.to_s, ".*")
48
+ basename = "template" if basename.empty?
49
+ File.join(@directory, "#{basename}-#{key}.rb")
50
+ end
51
+
52
+ def read(source_path, key)
53
+ target = path_for(source_path, key)
54
+ return nil unless target && File.file?(target)
55
+
56
+ File.read(target)
57
+ rescue SystemCallError
58
+ nil
59
+ end
60
+
61
+ def write(source_path, key, ruby)
62
+ target = path_for(source_path, key)
63
+ return ruby unless target
64
+
65
+ FileUtils.mkdir_p(File.dirname(target))
66
+ temporary = "#{target}.#{Process.pid}.#{rand(1 << 24)}.tmp"
67
+ File.binwrite(temporary, ruby)
68
+ File.rename(temporary, target)
69
+ ruby
70
+ rescue SystemCallError
71
+ # A read-only or missing cache directory must never break rendering.
72
+ @enabled = false
73
+ ruby
74
+ end
75
+
76
+ def clear
77
+ @lock.synchronize { @memory.clear }
78
+ return unless enabled? && File.directory?(@directory)
79
+
80
+ Dir.glob(File.join(@directory, "*.rb")).each { |file| File.delete(file) }
81
+ Dir.glob(File.join(@directory, "*.tmp")).each { |file| File.delete(file) }
82
+ end
83
+ end
84
+ end