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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSX
4
+ VERSION = "0.1.0"
5
+
6
+ # Bumping this invalidates every on-disk compile cache entry.
7
+ COMPILER_VERSION = "1"
8
+ end
data/lib/rsx-rb.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler requires a file matching the gem name. The library lives in rsx.rb.
4
+ require_relative "rsx"
data/lib/rsx.rb ADDED
@@ -0,0 +1,430 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ require_relative "rsx/version"
6
+ require_relative "rsx/errors"
7
+ require_relative "rsx/safe_string"
8
+ require_relative "rsx/escape"
9
+ require_relative "rsx/attributes"
10
+ require_relative "rsx/children"
11
+ require_relative "rsx/nodes"
12
+ require_relative "rsx/codegen"
13
+ require_relative "rsx/transformer"
14
+ require_relative "rsx/cache"
15
+ require_relative "rsx/compile_cache"
16
+ require_relative "rsx/component"
17
+ require_relative "rsx/context"
18
+ require_relative "rsx/loader"
19
+ require_relative "rsx/template"
20
+
21
+ # RSX brings JSX authoring to Ruby.
22
+ #
23
+ # component Greeting do |name:|
24
+ # return <p className="greeting">Hello, {name}!</p>
25
+ # end
26
+ #
27
+ # Markup is compiled ahead of time into Ruby string building, so rendering is
28
+ # just concatenation and escaping. See README.md for the full language guide.
29
+ module RSX
30
+ EMPTY_SAFE = SafeString.new("").freeze
31
+
32
+ # Slots for markup that never changes, populated on first render by compiled
33
+ # templates. Keys are generated at compile time, one per call site.
34
+ STATICS = {}
35
+
36
+ # Configuration is intentionally small: where to find components, where to put
37
+ # compiled output, and which cache to use.
38
+ class Configuration
39
+ # Directories searched for .rsx files and imports.
40
+ attr_accessor :paths
41
+
42
+ # Where compiled Ruby is cached. Set to nil to compile in memory only.
43
+ attr_reader :cache_dir
44
+
45
+ # Any object responding to fetch(key, expires_in:) { } and clear.
46
+ attr_writer :cache_store
47
+
48
+ # Namespace that `component Name` constants are defined under.
49
+ attr_accessor :component_namespace
50
+
51
+ # Reload changed .rsx files automatically (development).
52
+ attr_accessor :reload
53
+
54
+ def initialize
55
+ @paths = []
56
+ @component_namespace = Object
57
+ @reload = false
58
+ self.cache_dir = default_cache_dir
59
+ end
60
+
61
+ def cache_dir=(directory)
62
+ @cache_dir = directory
63
+ @compile_cache = CompileCache.new(directory)
64
+ end
65
+
66
+ def compile_cache
67
+ @compile_cache ||= CompileCache.new(@cache_dir)
68
+ end
69
+
70
+ def cache_store
71
+ @cache_store ||= Cache::Memory.new
72
+ end
73
+
74
+ private
75
+
76
+ def default_cache_dir
77
+ if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
78
+ ::Rails.root.join("tmp/cache/rsx").to_s
79
+ else
80
+ File.join(Dir.pwd, "tmp", "cache", "rsx")
81
+ end
82
+ end
83
+ end
84
+
85
+ class << self
86
+ # ------------------------------------------------------------------
87
+ # Configuration
88
+ # ------------------------------------------------------------------
89
+
90
+ def config
91
+ @config ||= Configuration.new
92
+ end
93
+
94
+ def configure
95
+ yield config
96
+ config
97
+ end
98
+
99
+ def loader
100
+ @loader ||= Loader.new(config)
101
+ end
102
+
103
+ def cache
104
+ config.cache_store
105
+ end
106
+
107
+ def reset!
108
+ @config = nil
109
+ @loader = nil
110
+ @templates = nil
111
+ STATICS.clear
112
+ end
113
+
114
+ # ------------------------------------------------------------------
115
+ # Compiling and loading
116
+ # ------------------------------------------------------------------
117
+
118
+ # Transforms .rsx source into plain Ruby. Useful for debugging: `rsx compile`.
119
+ def compile(source, path: nil)
120
+ Transformer.transform(source, path: path)
121
+ end
122
+
123
+ def load(path)
124
+ loader.load(path)
125
+ end
126
+
127
+ def load_all(paths = config.paths)
128
+ loader.load_all(paths)
129
+ end
130
+
131
+ def reload!
132
+ loader.reload!
133
+ end
134
+
135
+ # Compiles every .rsx file under the configured paths and warms the on-disk
136
+ # cache. Run at boot (or from `rake rsx:precompile`) so requests never
137
+ # compile anything.
138
+ def precompile!(paths = config.paths)
139
+ loader.files(paths).each do |file|
140
+ source = File.read(file)
141
+ config.compile_cache.fetch(file, source) { Transformer.transform(source, path: file) }
142
+ end
143
+ end
144
+
145
+ # The compiled Template for a markup .rsx file.
146
+ def template(path)
147
+ entry = loader.load(loader.resolve!(path))
148
+ entry.template || raise(Error, "#{entry.path} defines components; render one of them instead")
149
+ end
150
+
151
+ # ------------------------------------------------------------------
152
+ # Rendering
153
+ # ------------------------------------------------------------------
154
+
155
+ # Renders a component, a .rsx file path, or a lambda component.
156
+ #
157
+ # RSX.render(UserProfile, user: user)
158
+ # RSX.render("app/components/user_profile.rsx", user: user)
159
+ def render(target, context: nil, **props)
160
+ if target.is_a?(String) || target.respond_to?(:to_path)
161
+ render_file(target.to_s, context: context, **props)
162
+ else
163
+ safe(render_component(target, props.empty? ? nil : props, nil, context))
164
+ end
165
+ end
166
+
167
+ def render_file(path, context: nil, **props)
168
+ entry = loader.load(loader.resolve!(path))
169
+ props = props.empty? ? nil : props
170
+ component = entry.renderable
171
+
172
+ if component
173
+ safe(render_component(component, props, nil, context))
174
+ else
175
+ entry.template.render(props, context: context)
176
+ end
177
+ end
178
+
179
+ # Renders .rsx source directly. Handy in tests and scripts.
180
+ def render_source(source, path: nil, context: nil, **props)
181
+ Template.new(source, path: path).render(props.empty? ? nil : props, context: context)
182
+ end
183
+
184
+ # Internal: wraps the value of a compiled ActionView template.
185
+ def template_result(value)
186
+ safe(child(value))
187
+ end
188
+
189
+ # Internal: emitted by compiled markup for every <Component /> tag.
190
+ def render_component(target, props = nil, children = nil, parent = nil)
191
+ if children
192
+ props = props ? props : {}
193
+ props[:children] = children
194
+ end
195
+
196
+ case target
197
+ when Proc
198
+ child(target.arity.zero? ? target.call : target.call(props || Component::EMPTY_PROPS))
199
+ when String, Symbol
200
+ render_component(lookup_component(target), props, nil, parent)
201
+ else
202
+ unless target.respond_to?(:rsx_call)
203
+ raise UnknownComponentError,
204
+ "#{target.inspect} is not renderable. Components are defined with " \
205
+ "`component Name do ... end` in a .rsx file."
206
+ end
207
+
208
+ target.rsx_call(props, parent)
209
+ end
210
+ end
211
+
212
+ def lookup_component(name)
213
+ name = name.to_s
214
+ namespace = config.component_namespace
215
+ return namespace.const_get(name) if constant_defined?(name)
216
+
217
+ resolved = loader.resolve(name)
218
+ return loader.default_export(resolved) if resolved
219
+
220
+ raise UnknownComponentError, "no component named #{name}"
221
+ end
222
+
223
+ # ------------------------------------------------------------------
224
+ # Runtime emitted by compiled markup
225
+ # ------------------------------------------------------------------
226
+
227
+ # Coerces any value into markup, escaping it unless it is already safe.
228
+ # Mirrors React: nil, true and false render nothing, arrays are concatenated.
229
+ def child(value)
230
+ case value
231
+ when SafeString then value
232
+ when String then Escape.safe?(value) ? value : Escape.html(value)
233
+ when nil, true, false then EMPTY_SAFE
234
+ when Children then value.render
235
+ when Integer, Float, Symbol then value.to_s
236
+ when Array
237
+ out = +""
238
+ value.each { |item| out << child(item) }
239
+ out
240
+ when Component then value.rsx_perform
241
+ when Hash
242
+ raise Error, "cannot render a Hash. Did you mean to interpolate one of its values?"
243
+ else
244
+ if value.respond_to?(:html_safe?) && value.html_safe?
245
+ value.to_s
246
+ elsif value.respond_to?(:to_rsx)
247
+ child(value.to_rsx)
248
+ elsif value.is_a?(Class) && value < Component
249
+ raise Error, "cannot render the component class #{value.name} as a value. " \
250
+ "Write <#{value.name} /> to render it."
251
+ else
252
+ Escape.html(value.to_s)
253
+ end
254
+ end
255
+ end
256
+
257
+ # Wraps markup that the compiler proved static. Called once per call site.
258
+ def static(literal)
259
+ SafeString.new(literal).freeze
260
+ end
261
+
262
+ # Wraps an already-escaped or trusted string without copying when possible.
263
+ def safe(value)
264
+ value.is_a?(SafeString) ? value : SafeString.new(value.to_s)
265
+ end
266
+
267
+ # Marks a string as trusted HTML. The equivalent of Rails' `html_safe`.
268
+ def raw(value)
269
+ return EMPTY_SAFE if value.nil?
270
+
271
+ value.is_a?(SafeString) ? value : SafeString.new(value.to_s)
272
+ end
273
+
274
+ def escape(value)
275
+ SafeString.new(Escape.html(value.to_s))
276
+ end
277
+
278
+ # dangerouslySetInnerHTML={{ __html: markup }}
279
+ def raw_html(value)
280
+ return EMPTY_SAFE if value.nil?
281
+
282
+ html = value.respond_to?(:[]) && !value.is_a?(String) ? (value[:__html] || value["__html"]) : value
283
+ html.nil? ? EMPTY_SAFE : html.to_s
284
+ end
285
+
286
+ def json(object)
287
+ require "json"
288
+ JSON.generate(object)
289
+ end
290
+
291
+ # ------------------------------------------------------------------
292
+ # Defining components
293
+ # ------------------------------------------------------------------
294
+
295
+ # Emitted by `component Name do |props| ... end`.
296
+ def define_component(name, cache: nil, static: false, &body)
297
+ component = Class.new(Component)
298
+ assign_constant(name, component)
299
+ component.cache(cache) if cache
300
+ component.rsx_static! if static
301
+ loader.track(component)
302
+ component.class_eval(&body)
303
+ component
304
+ end
305
+
306
+ def create_context(default = nil, name: nil)
307
+ Context.new(default, name: name)
308
+ end
309
+
310
+ # Emitted by `import Name from "path"`.
311
+ def import(spec, as: nil, from: nil)
312
+ from = nil if from.to_s.empty?
313
+ loader.import(spec, as: as, from: from)
314
+ end
315
+
316
+ # Emitted by `export default Name`.
317
+ def export_default(component, from: nil)
318
+ entry = loader.current_entry || (from && loader.entries.find { |candidate| candidate.path == from })
319
+ entry.default = component if entry
320
+ component
321
+ end
322
+
323
+ # Emitted by `export Name`.
324
+ def export(component, from: nil)
325
+ entry = loader.current_entry || (from && loader.entries.find { |candidate| candidate.path == from })
326
+ if entry && !entry.components.include?(component)
327
+ entry.components << component
328
+ end
329
+ component
330
+ end
331
+
332
+ # ------------------------------------------------------------------
333
+ # Constants
334
+ # ------------------------------------------------------------------
335
+
336
+ def assign_constant(path, value)
337
+ parts = path.to_s.split("::")
338
+ target = config.component_namespace
339
+
340
+ parts[0..-2].each do |part|
341
+ target = if target.const_defined?(part, false)
342
+ target.const_get(part, false)
343
+ else
344
+ target.const_set(part, Module.new)
345
+ end
346
+ end
347
+
348
+ last = parts.last
349
+ target.send(:remove_const, last) if target.const_defined?(last, false)
350
+ target.const_set(last, value)
351
+ value
352
+ end
353
+
354
+ def constant_defined?(path)
355
+ parts = path.to_s.split("::")
356
+ return false unless parts.all? { |part| part.match?(/\A[A-Z][A-Za-z0-9_]*\z/) }
357
+
358
+ target = config.component_namespace
359
+ parts.each do |part|
360
+ return false unless target.is_a?(Module) && target.const_defined?(part, false)
361
+
362
+ target = target.const_get(part, false)
363
+ end
364
+ true
365
+ end
366
+ alias const_defined_at? constant_defined?
367
+
368
+ def remove_constant(component)
369
+ name = component.name
370
+ return if name.nil?
371
+
372
+ parts = name.split("::")
373
+ target = Object
374
+ parts[0..-2].each do |part|
375
+ return unless target.const_defined?(part, false)
376
+
377
+ target = target.const_get(part, false)
378
+ end
379
+ last = parts.last
380
+ return unless target.const_defined?(last, false)
381
+ return unless target.const_get(last, false).equal?(component)
382
+
383
+ target.send(:remove_const, last)
384
+ end
385
+
386
+ # ------------------------------------------------------------------
387
+ # Cache helpers
388
+ # ------------------------------------------------------------------
389
+
390
+ def normalize_cache_options(options)
391
+ case options
392
+ when true then {}
393
+ when false, nil then nil
394
+ when Hash then options
395
+ when Numeric then { expires_in: options }
396
+ when Proc then { key: options }
397
+ else raise ArgumentError, "unsupported cache option #{options.inspect}"
398
+ end
399
+ end
400
+
401
+ # Builds a short, stable cache key fragment from arbitrary Ruby values.
402
+ def stable_key(object)
403
+ fragment = key_fragment(object)
404
+ fragment.length > 128 ? Digest::SHA256.hexdigest(fragment)[0, 32] : fragment
405
+ end
406
+
407
+ def key_fragment(object)
408
+ case object
409
+ when nil then "~"
410
+ when String then object
411
+ when Symbol, Numeric, true, false then object.to_s
412
+ when Array then object.map { |item| key_fragment(item) }.join(",")
413
+ when Hash then object.map { |key, value| "#{key}=#{key_fragment(value)}" }.join("&")
414
+ when Time then object.to_f.to_s
415
+ else
416
+ if object.respond_to?(:cache_key_with_version)
417
+ object.cache_key_with_version.to_s
418
+ elsif object.respond_to?(:cache_key)
419
+ object.cache_key.to_s
420
+ elsif object.respond_to?(:id) && object.respond_to?(:updated_at)
421
+ "#{object.class}/#{object.id}-#{object.updated_at.to_i}"
422
+ else
423
+ object.to_s
424
+ end
425
+ end
426
+ end
427
+ end
428
+ end
429
+
430
+ require_relative "rsx/railtie" if defined?(::Rails::Railtie)
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rsx-rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - RSX contributors
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-08-22 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |
14
+ RSX is a template language that brings React's JSX authoring model to Ruby.
15
+ Write .rsx files where Ruby replaces JavaScript and markup is embedded directly
16
+ in expression position, including fragments (<>), expression containers ({}),
17
+ spread attributes and composable components. Templates compile ahead of time to
18
+ plain Ruby string building, so rendering is fast and cacheable. Zero runtime
19
+ dependencies; Rails integration is optional and loads automatically when present.
20
+ email:
21
+ executables:
22
+ - rsx
23
+ extensions: []
24
+ extra_rdoc_files: []
25
+ files:
26
+ - CHANGELOG.md
27
+ - LICENSE
28
+ - README.md
29
+ - examples/components/button.rsx
30
+ - examples/components/card.rsx
31
+ - examples/components/sidebar.rsx
32
+ - examples/components/theme.rsx
33
+ - examples/components/user_table.rsx
34
+ - examples/user_profile.rsx
35
+ - examples/views/dashboard.html.rsx
36
+ - exe/rsx
37
+ - lib/rsx-rb.rb
38
+ - lib/rsx.rb
39
+ - lib/rsx/attributes.rb
40
+ - lib/rsx/cache.rb
41
+ - lib/rsx/children.rb
42
+ - lib/rsx/cli.rb
43
+ - lib/rsx/codegen.rb
44
+ - lib/rsx/compile_cache.rb
45
+ - lib/rsx/component.rb
46
+ - lib/rsx/context.rb
47
+ - lib/rsx/errors.rb
48
+ - lib/rsx/escape.rb
49
+ - lib/rsx/helpers.rb
50
+ - lib/rsx/loader.rb
51
+ - lib/rsx/nodes.rb
52
+ - lib/rsx/railtie.rb
53
+ - lib/rsx/safe_string.rb
54
+ - lib/rsx/tasks.rake
55
+ - lib/rsx/template.rb
56
+ - lib/rsx/template_handler.rb
57
+ - lib/rsx/transformer.rb
58
+ - lib/rsx/version.rb
59
+ homepage: https://github.com/rsx-rb/rsx
60
+ licenses:
61
+ - MIT
62
+ metadata:
63
+ homepage_uri: https://github.com/rsx-rb/rsx
64
+ source_code_uri: https://github.com/rsx-rb/rsx
65
+ changelog_uri: https://github.com/rsx-rb/rsx/blob/main/CHANGELOG.md
66
+ rubygems_mfa_required: 'true'
67
+ post_install_message:
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: 3.0.0
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 3.5.3
83
+ signing_key:
84
+ specification_version: 4
85
+ summary: JSX-style templates for Ruby and Rails.
86
+ test_files: []