rsx-rb 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c8c99446f5f4a181f5076fc1c953abe88019abe2d88648f3b2838065103b9d01
4
- data.tar.gz: 756fc7f9d2b864fd4f8bc3bed5f7f841fa9f07fc0659ebaaa432da0caaa6c960
3
+ metadata.gz: '09ece5a47645dffe35d921bbf2515c6dd929b3946975c26f2c054ebf44eba867'
4
+ data.tar.gz: 557cb7747622220af0b415dc321d47d5bf38701e41673d48d095efc900940ce2
5
5
  SHA512:
6
- metadata.gz: 21380274662f0aa00351a671c6742f4854d908e7e614bd61191111c038a08e0e5b00f545cebd442b502a6432bc350f1f2efde566dca012d0a0ceea142187ef00
7
- data.tar.gz: 19452fed00b1aaefcbeaf532216b0da8e032ae7cd07cd7b6a5a228f5fa4bdea966f0d1edd417eb98ed8ef32e9c14abadb9fd4fdb662cca8a52d85d552af5a510
6
+ metadata.gz: a3206858816384a46c2383d6361b0a29614815f7a556d6b95ba3682dd1eea75d4bf1de5edc45b3b54cb3967542d553c4947fb20676a6139a5f93a6c905aa160e
7
+ data.tar.gz: 9e1d5bcde977a58d87be474c8dbe9e1704ed38849e5c6e763fb8dd086c76ecc388b3db2215b296f63fabf9c0d94c171f782a767ef5e3e91ec4fa2fa1e65be26c
data/CHANGELOG.md CHANGED
@@ -5,11 +5,56 @@ All notable changes to RSX are documented here. This project follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
- - Publish the gem as `rsx-rb` on RubyGems. The require path remains `rsx`.
8
+ ## [0.2.0] - 2026-09-16
9
+
10
+ ### Fixed
11
+
12
+ - **Attribute name injection.** Hash keys for `data={...}` and `aria={...}` were written into the
13
+ tag without validation, so a key carrying a quote could close the attribute and inject another
14
+ one. Keys that are not usable attribute names now raise `ArgumentError`, in spreads too.
15
+ - `<script>` and `<style>` are parsed as raw text, as HTML defines them. CSS selectors with `>`,
16
+ JavaScript comparisons and object literals, and `"</div>"` inside a string no longer break
17
+ compilation. Dynamic content goes through `dangerouslySetInnerHTML`.
18
+ - Whitespace inside `<pre>` and `<textarea>` is preserved rather than joined the way JSX joins it,
19
+ which had been changing what the browser displayed.
20
+ - A void element given children or a closing tag (`<br></br>`) reports the tag and line instead of
21
+ raising a Ruby syntax error from the generated file.
22
+ - Duplicate attributes collapse at compile time keeping the last value, as React does, instead of
23
+ being emitted twice — which was invalid HTML and picked the first value.
24
+ - `Proc`, `Method`, `Hash` and `Array` attribute values raise instead of being inspected into the
25
+ document, so `onClick={-> { }}` no longer renders `onclick="#<Proc…>"`.
26
+ - Markup in argument position (`wrap <div>x</div>`) reports that it needs parentheses instead of
27
+ compiling to a chain of comparisons that fails elsewhere.
28
+ - `list <<x` is an append again; the second `<` was being read as the start of a tag.
29
+ - Static markup slots no longer accumulate for the life of the process: the loader drops a file's
30
+ slots when it reloads it. Filling a slot is synchronized, so it is no longer a bare `||=` on a
31
+ Hash shared between threads.
32
+ - Template resolution is confined to `.rsx` files inside the configured paths or the working
33
+ directory. It previously accepted absolute paths and `../`, so `render_file` on a value from a
34
+ request could name any file on disk — and loading a template evaluates it.
35
+ - `RSX.render_component` copies the props hash instead of writing `:children` into the one it was
36
+ given.
37
+
38
+ ### Added
39
+
40
+ - `.rsx` templates take part in Rails' template digests, so a `cache` block wrapping an `.rsx`
41
+ partial is invalidated when that partial, or anything it renders, changes.
42
+ - `rails generate rsx:component Card title body`.
43
+ - CI across supported Rubies and ActionView versions, including a run without Rails, and a job
44
+ that installs the built gem and renders with it. The Rails suite previously skipped in full
45
+ whenever ActionView was absent, which was always.
46
+ - RuboCop, configured to the style the code already uses.
47
+
48
+ ### Changed
49
+
50
+ - `COMPILER_VERSION` is `2`: generated Ruby changed, so cached output from 0.1.0 is recompiled
51
+ automatically on first use.
52
+ - `RSX::CompileCache#fetch` is now `fetch_or_compile`, since both arguments identify the entry and
53
+ the old name read like `Hash#fetch`. Internal.
9
54
 
10
55
  ## [0.1.0]
11
56
 
12
- First release.
57
+ First release, published as `rsx-rb` on RubyGems. The require path is `rsx`.
13
58
 
14
59
  - `.rsx` templates: JSX syntax with Ruby in place of JavaScript — `<>` fragments, `{}`
15
60
  expression containers, `{/* comments */}`, JSX whitespace rules, void and self-closing
data/README.md CHANGED
@@ -19,6 +19,20 @@ export default Greeting
19
19
 
20
20
  [Example: RSX with Tailwind CSS](https://github.com/derwydd/rsx-working-example)
21
21
 
22
+ Install it from RubyGems as **`rsx-rb`** (`rsx` is already taken):
23
+
24
+ ```ruby
25
+ # Gemfile
26
+ gem "rsx-rb"
27
+ ```
28
+
29
+ ```bash
30
+ bundle install
31
+ # or: gem install rsx-rb
32
+ ```
33
+
34
+ Then `require "rsx"`. Bundler does that for you.
35
+
22
36
  Templates are compiled ahead of time into plain Ruby string building, so rendering is
23
37
  concatenation and escaping — no interpreter, no virtual DOM, no diffing. RSX has **zero runtime
24
38
  dependencies**; the Rails integration activates itself only when Rails is already loaded.
@@ -306,6 +320,15 @@ rows = items.map { |i| <li>{i}</li> } # block body
306
320
  return <p>{n < 10 ? "few" : "many"}</p> # comparison inside a container
307
321
  ```
308
322
 
323
+ A method call is a value, so markup as a bare argument needs parentheses — `wrap <div>x</div>`
324
+ would be a chain of comparisons to Ruby. RSX reports that where you wrote it instead of letting
325
+ it compile:
326
+
327
+ ```ruby
328
+ wrap(<div>x</div>) # ✓
329
+ wrap <div>x</div> # ✗ "markup here needs parentheses"
330
+ ```
331
+
309
332
  ### Fragments
310
333
 
311
334
  Multiple sibling elements need one parent. Use `<>...</>` when you do not want a wrapper
@@ -406,6 +429,28 @@ joined with a single space. So this…
406
429
 
407
430
  …renders `<p>Hello, world</p>`. Use `{" "}` when you need a space JSX would have collapsed.
408
431
 
432
+ Inside `<pre>` and `<textarea>` whitespace is significant, so RSX leaves it exactly as written
433
+ rather than joining lines. This is one of the few places RSX deliberately parts ways with JSX,
434
+ which would collapse it and change what the browser displays.
435
+
436
+ ### `<script>` and `<style>`
437
+
438
+ HTML treats these two as *raw text*: no child elements, no character references. RSX does the
439
+ same, so their contents are passed through byte for byte and nothing inside is markup:
440
+
441
+ ```ruby
442
+ <style>.card > .title { color: red }</style>
443
+ <script>if (a < b) { render({ x: 1 }); }</script>
444
+ ```
445
+
446
+ Selectors with `>`, JavaScript comparisons, object literals and `"</div>"` inside a string all
447
+ work as written. Since the body is not parsed, `{}` is not an expression container either — to
448
+ put dynamic content in a script, use `dangerouslySetInnerHTML`:
449
+
450
+ ```ruby
451
+ <script dangerouslySetInnerHTML={{ __html: "window.config = #{RSX.json(config)}" }} />
452
+ ```
453
+
409
454
  ### Escaping and raw HTML
410
455
 
411
456
  Interpolated values are HTML-escaped. Strings already marked safe (RSX's own output, and
@@ -477,6 +522,11 @@ Following React, booleans become the strings `"true"`/`"false"`, and `nil` drops
477
522
  # => <div data-user-id="7" data-ids="[1,2]" aria-label="Close" aria-hidden="true"></div>
478
523
  ```
479
524
 
525
+ Values are escaped, but a *name* is written into the tag as-is, so it cannot be. A key that is
526
+ not a usable attribute name — one carrying a space, a quote or an angle bracket — raises
527
+ `ArgumentError` rather than being emitted, since otherwise a key built from untrusted input
528
+ could close the attribute and start another one. The same applies to spread keys.
529
+
480
530
  ### Spread
481
531
 
482
532
  Both the JSX and the Ruby spelling are accepted:
@@ -491,6 +541,10 @@ the same HTML attribute collapse, keeping the last value. So `className="link"`
491
541
  a `class` or `className` coming from `attrs`, rather than emitting the attribute twice. A `nil`
492
542
  or `false` spread contributes nothing.
493
543
 
544
+ Two attributes naming the same HTML attribute collapse the same way without a spread, at compile
545
+ time: `<div className="a" className="b" />` renders `class="b"`. Emitting both would be invalid
546
+ HTML *and* pick the opposite winner, since browsers keep the first.
547
+
494
548
  Spread works on components too, where it becomes keyword arguments.
495
549
 
496
550
  ### Event handlers
@@ -505,6 +559,10 @@ There is no client-side runtime, so handlers are strings — the value of an HTM
505
559
  For real interactivity, use the attributes your JS framework expects
506
560
  (`data-controller`, `data-action`, `hx-post`, …) — they pass through untouched.
507
561
 
562
+ Passing a lambda raises, rather than writing `#<Proc…>` into the document. So do a `Hash` or
563
+ `Array` given to an attribute that does not take one — only `class` accepts an Array, and only
564
+ `class`, `style`, `data` and `aria` accept a Hash.
565
+
508
566
  ### Void and self-closing elements
509
567
 
510
568
  Void elements never get a closing tag, whether or not you write `/`:
@@ -515,6 +573,9 @@ Void elements never get a closing tag, whether or not you write `/`:
515
573
  <circle r={4} /> # SVG keeps XML self-closing syntax => <circle r="4"/>
516
574
  ```
517
575
 
576
+ They also take no children and have no closing tag, so `<br></br>` and `<img src={u}>alt</img>`
577
+ are reported as errors where they are written.
578
+
518
579
  ---
519
580
 
520
581
  ## Components
@@ -820,6 +881,25 @@ Any view, partial or layout can be `.html.rsx`. Inside one, `self` is the view c
820
881
  Partial locals are local variables, exactly as in ERB. Output is html-safe, so `.rsx` and ERB
821
882
  templates can render each other freely.
822
883
 
884
+ Rails' strict locals comment works too, since it is an ordinary Ruby comment in `.rsx`:
885
+
886
+ ```ruby
887
+ # locals: (author:, byline_class: "byline")
888
+ <p className={byline_class}>{author.name}</p>
889
+ ```
890
+
891
+ `.rsx` templates also take part in Rails' template digests, so a `cache` block wrapping an `.rsx`
892
+ partial is invalidated when that partial — or any partial it renders — changes.
893
+
894
+ ### Generating a component
895
+
896
+ ```bash
897
+ bin/rails generate rsx:component Card title body
898
+ ```
899
+
900
+ Writes `app/components/card.rsx` (or into the first entry of `config.rsx.paths`) with `title:` and
901
+ `body:` as keyword props.
902
+
823
903
  ### Components from ERB, Haml or Slim
824
904
 
825
905
  ```erb
@@ -914,7 +994,7 @@ where compiled output goes, `-p/--prop NAME=VALUE` passes a string prop.
914
994
 
915
995
  | Setting | Default | Meaning |
916
996
  | --- | --- | --- |
917
- | `paths` | `app/components`, `app/rsx` (Rails) | Directories searched for `.rsx` files and imports |
997
+ | `paths` | `app/components`, `app/rsx` (Rails) | Directories searched for `.rsx` files and imports; also the limit of what can be resolved |
918
998
  | `cache_dir` | `tmp/cache/rsx` | Where compiled Ruby is stored; `nil` compiles in memory |
919
999
  | `cache_store` | `RSX::Cache::Memory` | Store for component and fragment caches |
920
1000
  | `component_namespace` | `Object` | Module that `component Name` constants are defined under |
@@ -924,6 +1004,11 @@ Useful entry points on the `RSX` module: `compile`, `load`, `load_all`, `reload!
924
1004
  `precompile!`, `render`, `render_file`, `render_source`, `template`, `lookup_component`,
925
1005
  `create_context`, `cache`, `config`, `configure`, `reset!`.
926
1006
 
1007
+ Loading a template evaluates it, so resolution only ever matches `.rsx` files inside `paths` or
1008
+ the working directory — `../` cannot climb out of them, and an absolute path elsewhere is refused.
1009
+ A path from a request therefore cannot reach anything else on disk, though it is still better to
1010
+ map user input to a known set of templates than to pass it to `render_file` directly.
1011
+
927
1012
  ---
928
1013
 
929
1014
  ## Differences from React
@@ -940,9 +1025,16 @@ runtime differences are worth stating plainly:
940
1025
  `&&`/`||` semantics differ around `0` and `""`, and `nil` replaces `null`/`undefined`.
941
1026
  - **Expression containers only exist inside markup.** At the top level of a template file, `{}`
942
1027
  is a Ruby hash.
943
- - **Whitespace, escaping, fragments, spread, `dangerouslySetInnerHTML`, `className`/`style`
944
- handling, boolean and `data`/`aria` attributes, children, render props, context, and
945
- `import`/`export default`** all behave as they do in React.
1028
+ - **`<pre>` and `<textarea>` keep their whitespace** instead of having it collapsed. JSX collapses
1029
+ it, which for server-rendered HTML just changes what the browser shows.
1030
+ - **`<script>` and `<style>` are raw text**, as HTML defines them, so `{}` is not an expression
1031
+ container inside them. Use `dangerouslySetInnerHTML` for dynamic script or style content.
1032
+ - **Mistakes are errors, not output.** A void element with children, a lambda in an attribute,
1033
+ and an attribute name that would break out of its quotes all raise instead of rendering
1034
+ something surprising.
1035
+ - **Escaping, fragments, spread, `dangerouslySetInnerHTML`, `className`/`style` handling, boolean
1036
+ and `data`/`aria` attributes, children, render props, context, and `import`/`export default`**
1037
+ all behave as they do in React.
946
1038
 
947
1039
  ---
948
1040
 
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "rails/generators/named_base"
5
+
6
+ module RSX
7
+ module Generators
8
+ # rails generate rsx:component Card title body
9
+ #
10
+ # Writes the component into the first configured RSX path, so the file lands
11
+ # somewhere the loader already looks.
12
+ class ComponentGenerator < ::Rails::Generators::NamedBase
13
+ # Thor derives a namespace by inserting an underscore before every capital,
14
+ # which turns RSX into "r_s_x". Declare the real one.
15
+ namespace "rsx:component"
16
+
17
+ source_root File.expand_path("templates", __dir__)
18
+
19
+ desc "Creates an RSX component with one keyword prop per given name."
20
+
21
+ argument :props, type: :array, default: [], banner: "prop prop"
22
+
23
+ def create_component_file
24
+ template "component.rsx.tt", File.join(component_root, "#{file_path}.rsx")
25
+ end
26
+
27
+ private
28
+
29
+ DEFAULT_ROOT = "app/components"
30
+
31
+ def component_root
32
+ configured = Array(::RSX.config.paths).first
33
+ return DEFAULT_ROOT if configured.nil?
34
+
35
+ relative = Pathname.new(configured.to_s).relative_path_from(Pathname.new(destination_root)).to_s
36
+ relative.start_with?("..") ? DEFAULT_ROOT : relative
37
+ rescue ArgumentError
38
+ # The configured path is on another volume, so it cannot be expressed
39
+ # relative to the application root.
40
+ DEFAULT_ROOT
41
+ end
42
+
43
+ def parameter_list
44
+ return "props" if props.empty?
45
+
46
+ props.map { |prop| "#{prop}:" }.join(", ")
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,15 @@
1
+ component <%= class_name %> do |<%= parameter_list %>|
2
+ return (
3
+ <div className="<%= file_name.dasherize %>">
4
+ <% if props.any? -%>
5
+ <% props.each do |prop| -%>
6
+ <p className="<%= file_name.dasherize %>__<%= prop.dasherize %>">{<%= prop %>}</p>
7
+ <% end -%>
8
+ <% else -%>
9
+ <p><%= class_name %></p>
10
+ <% end -%>
11
+ </div>
12
+ )
13
+ end
14
+
15
+ export default <%= class_name %>
@@ -111,6 +111,15 @@ module RSX
111
111
  feTurbulence mpath set
112
112
  ].to_h { |name| [name, true] }.freeze
113
113
 
114
+ # Elements whose content is raw text rather than markup. HTML gives these no
115
+ # child elements and no character references, so `<`, `>` and `{` inside them
116
+ # are ordinary characters: CSS selectors and JavaScript work as written.
117
+ RAW_TEXT = %w[script style].to_h { |name| [name, true] }.freeze
118
+
119
+ # Elements where whitespace is significant, so JSX's line-joining rules
120
+ # would change what the browser displays.
121
+ PREFORMATTED = %w[pre textarea listing plaintext].to_h { |name| [name, true] }.freeze
122
+
114
123
  # CSS properties that take a bare number (everything else gets "px").
115
124
  UNITLESS_CSS = %w[
116
125
  animation-iteration-count aspect-ratio border-image-outset border-image-slice
@@ -126,11 +135,23 @@ module RSX
126
135
  # Props that describe the element to RSX rather than to the browser.
127
136
  IGNORED = %w[key ref children suppressHydrationWarning].to_h { |name| [name, true] }.freeze
128
137
 
129
- INVALID_NAME = %r{[\s"'>/=\0]}
138
+ INVALID_NAME = %r{[\s"'<>/=\0]}
130
139
  CAMEL_BOUNDARY = /([a-z0-9])([A-Z])/
131
140
 
132
141
  module_function
133
142
 
143
+ # Attribute names are written into the tag verbatim, so a name carrying a
144
+ # quote or a space would end the attribute and start a new one. Values are
145
+ # escaped, but names cannot be, which is why anything malformed is refused
146
+ # rather than mangled into something that still parses as HTML.
147
+ def validate_name!(name)
148
+ return name unless name.empty? || name.match?(INVALID_NAME)
149
+
150
+ raise ArgumentError,
151
+ "#{name.inspect} is not a usable HTML attribute name. Attribute names are " \
152
+ "written into the tag as-is, so they cannot come from untrusted input."
153
+ end
154
+
134
155
  # Maps a prop name to its HTML attribute name, or nil when the prop should
135
156
  # not be rendered at all.
136
157
  def attribute_name(prop)
@@ -158,15 +179,41 @@ module RSX
158
179
  SELF_CLOSING.key?(tag)
159
180
  end
160
181
 
182
+ def raw_text?(tag)
183
+ RAW_TEXT.key?(tag)
184
+ end
185
+
186
+ def preformatted?(tag)
187
+ PREFORMATTED.key?(tag)
188
+ end
189
+
161
190
  # Renders one attribute, including its leading space: ` href="/x"`.
162
191
  def render(name, value)
163
192
  case value
164
193
  when nil, false then ""
165
194
  when true then boolean?(name) ? " #{name}" : %( #{name}="true")
195
+ when Proc, Method, Hash, Array then raise ArgumentError, unrenderable(name, value)
166
196
  else %( #{name}="#{Escape.attribute(value)}")
167
197
  end
168
198
  end
169
199
 
200
+ # An attribute can only ever be a string, so these types are always a mistake
201
+ # rather than a value to inspect into the document.
202
+ def unrenderable(name, value)
203
+ advice =
204
+ case value
205
+ when Proc, Method
206
+ "Event handlers are HTML attributes, not callbacks: pass the JavaScript " \
207
+ "to run as a string, as in onClick=\"submit()\"."
208
+ when Hash
209
+ "Only class, style, data and aria accept a Hash."
210
+ when Array
211
+ "Only class accepts an Array."
212
+ end
213
+
214
+ "cannot render #{value.class} as the #{name} attribute. #{advice}"
215
+ end
216
+
170
217
  # class={...} accepts a String, Symbol, Array or Hash.
171
218
  def render_class(value)
172
219
  tokens = class_tokens(value)
@@ -179,7 +226,6 @@ module RSX
179
226
  case value
180
227
  when nil, false, true then []
181
228
  when String then value.empty? ? [] : [value]
182
- when Symbol then [value.to_s]
183
229
  when Array then value.flat_map { |item| class_tokens(item) }
184
230
  when Hash then value.filter_map { |token, on| token.to_s if on }
185
231
  else [value.to_s]
@@ -235,7 +281,8 @@ module RSX
235
281
  # Like React, data-* and aria-* keep booleans as the strings "true"
236
282
  # and "false" rather than becoming bare attributes: ARIA values are
237
283
  # enumerated, so `aria-hidden` alone means nothing.
238
- %( #{prefix}-#{css_property(key)}="#{Escape.attribute(nested_value(raw))}")
284
+ name = validate_name!("#{prefix}-#{css_property(key)}")
285
+ %( #{name}="#{Escape.attribute(nested_value(raw))}")
239
286
  end.join
240
287
  else render(prefix, value)
241
288
  end
@@ -302,9 +349,9 @@ module RSX
302
349
  when "dangerouslySetInnerHTML" then next
303
350
  else
304
351
  name = attribute_name(prop)
305
- next if name.nil? || name.empty? || name.match?(INVALID_NAME)
352
+ next if name.nil? || name.empty?
306
353
 
307
- out << render(name, value)
354
+ out << render(validate_name!(name), value)
308
355
  end
309
356
  end
310
357
  out
data/lib/rsx/children.rb CHANGED
@@ -46,9 +46,9 @@ module RSX
46
46
  end
47
47
 
48
48
  # Render props: {children.call(item)} passes a value back to the caller.
49
- def call(*arguments, **options, &block)
49
+ def call(...)
50
50
  raw = value
51
- return raw.call(*arguments, **options, &block) if raw.respond_to?(:call)
51
+ return raw.call(...) if raw.respond_to?(:call)
52
52
 
53
53
  raw
54
54
  end
data/lib/rsx/cli.rb CHANGED
@@ -37,7 +37,10 @@ module RSX
37
37
  opts.on("-c", "--cache-dir DIR", "Directory for compiled output") do |dir|
38
38
  options[:cache_dir] = dir
39
39
  end
40
- opts.on("-h", "--help", "Show this message") { puts opts; return 0 }
40
+ opts.on("-h", "--help", "Show this message") do
41
+ puts opts
42
+ return 0
43
+ end
41
44
  end
42
45
 
43
46
  arguments = parser.parse(argv)
@@ -75,6 +78,9 @@ module RSX
75
78
 
76
79
  def render(files, props)
77
80
  abort_missing(files)
81
+ # Naming a file on the command line is as explicit as it gets, so add its
82
+ # directory to the load path rather than have the loader refuse it.
83
+ RSX.config.paths |= files.map { |file| File.dirname(File.expand_path(file)) }
78
84
  files.each { |file| puts RSX.render_file(File.expand_path(file), **props) }
79
85
  end
80
86
 
data/lib/rsx/codegen.rb CHANGED
@@ -49,6 +49,8 @@ module RSX
49
49
  case node
50
50
  when Nodes::Text
51
51
  parts << [:static, Escape.static_text(node.value), node.line]
52
+ when Nodes::RawText
53
+ parts << [:static, node.value, node.line]
52
54
  when Nodes::Expression
53
55
  # The extra parentheses let a container hold anything Ruby accepts as an
54
56
  # expression, including modifiers: {greeting if signed_in?}.
@@ -83,7 +85,7 @@ module RSX
83
85
  if attributes.any? { |attribute| attribute.kind == :spread }
84
86
  emit_spread_attributes(attributes, parts)
85
87
  else
86
- attributes.each { |attribute| emit_attribute(attribute, parts) }
88
+ dedupe(attributes).each { |attribute| emit_attribute(attribute, parts) }
87
89
  end
88
90
 
89
91
  if inner_html
@@ -99,11 +101,11 @@ module RSX
99
101
  end
100
102
 
101
103
  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
104
+ parts << if node.self_closing && Attributes.self_closing?(tag)
105
+ [:static, "/>", last_line(parts, node)]
106
+ else
107
+ [:static, "></#{tag}>", last_line(parts, node)]
108
+ end
107
109
  return
108
110
  end
109
111
 
@@ -139,6 +141,20 @@ module RSX
139
141
  parts << [:dynamic, source, attributes.first.line]
140
142
  end
141
143
 
144
+ # Two props naming the same HTML attribute would otherwise be written twice,
145
+ # which is invalid HTML and inverts the result: React keeps the last value,
146
+ # browsers keep the first. Matches Attributes.merge by holding the first
147
+ # position and taking the last value.
148
+ def dedupe(attributes)
149
+ return attributes if attributes.length < 2
150
+
151
+ # Reassigning a Hash key keeps its original position and takes the new
152
+ # value, which is the merge rule verbatim.
153
+ by_name = {}
154
+ attributes.each { |attribute| by_name[Attributes.canonical_name(attribute.name)] = attribute }
155
+ by_name.length == attributes.length ? attributes : by_name.values
156
+ end
157
+
142
158
  def attribute_pair(attribute)
143
159
  key = symbol_literal(attribute.name)
144
160
 
@@ -248,10 +264,14 @@ module RSX
248
264
  buffer = +""
249
265
 
250
266
  # 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(")
267
+ # per-call-site slot, so re-rendering it allocates nothing at all. Once the
268
+ # slot is filled the read short-circuits and the literal is never built.
269
+ if static
270
+ slot = static_slot
271
+ buffer << "(::RSX::STATICS[#{slot}] || ::RSX.define_static(#{slot}, "
272
+ else
273
+ buffer << "::RSX::SafeString.new("
274
+ end
255
275
  state = { line: start_line, open: false }
256
276
 
257
277
  parts.each do |kind, text, line|
@@ -25,8 +25,10 @@ module RSX
25
25
  Digest::SHA256.hexdigest("#{COMPILER_VERSION}\0#{source}")[0, 32]
26
26
  end
27
27
 
28
- # Returns the compiled Ruby for source, compiling only on a cache miss.
29
- def fetch(path, source)
28
+ # Returns the compiled Ruby for source, compiling only on a cache miss. Not
29
+ # named fetch: both arguments identify the entry, so a reader expecting
30
+ # Hash#fetch would take source for a default value.
31
+ def fetch_or_compile(path, source)
30
32
  key = digest(source)
31
33
  cached = @lock.synchronize { @memory[key] }
32
34
  return cached if cached
data/lib/rsx/component.rb CHANGED
@@ -14,12 +14,7 @@ module RSX
14
14
  EMPTY_PROPS = {}.freeze
15
15
 
16
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
17
+ attr_accessor :rsx_source_path, :rsx_source_digest, :rsx_cache_options
23
18
 
24
19
  # Marks a component whose output never varies. The compiler sets this
25
20
  # automatically when a component body is nothing but static markup.
@@ -66,7 +61,7 @@ module RSX
66
61
  custom = options[:key]
67
62
  payload =
68
63
  if custom.nil?
69
- (props || EMPTY_PROPS).reject { |key, _| key == :children }
64
+ (props || EMPTY_PROPS).except(:children)
70
65
  elsif custom.respond_to?(:arity) && custom.arity.zero?
71
66
  custom.call
72
67
  else
@@ -157,9 +152,7 @@ module RSX
157
152
 
158
153
  # The object that rendered this component: a view context in Rails, the
159
154
  # parent component when nested, or nil when rendered directly.
160
- def rsx_parent
161
- @rsx_parent
162
- end
155
+ attr_reader :rsx_parent
163
156
 
164
157
  # The nearest non-component render context, i.e. the Rails view. Gives access
165
158
  # to url helpers, form builders, `t`, asset helpers and anything else the
data/lib/rsx/loader.rb CHANGED
@@ -7,7 +7,8 @@ module RSX
7
7
  class Loader
8
8
  EXTENSIONS = [".rsx", ".html.rsx"].freeze
9
9
 
10
- Entry = Struct.new(:path, :digest, :mtime, :components, :default, :template, keyword_init: true) do
10
+ Entry = Struct.new(:path, :digest, :mtime, :components, :default, :template, :statics_prefix,
11
+ keyword_init: true) do
11
12
  # Markup files render through a template; component files render their
12
13
  # default export.
13
14
  def renderable
@@ -52,12 +53,13 @@ module RSX
52
53
 
53
54
  unload(existing) if existing
54
55
 
55
- entry = Entry.new(path: absolute, digest: digest, mtime: mtime(absolute), components: [], default: nil)
56
+ entry = Entry.new(path: absolute, digest: digest, mtime: mtime(absolute), components: [],
57
+ default: nil, statics_prefix: Transformer.static_prefix(source))
56
58
  @entries[absolute] = entry
57
59
  @stack.push(entry)
58
60
 
59
61
  begin
60
- ruby = compile_cache.fetch(absolute, source) do
62
+ ruby = compile_cache.fetch_or_compile(absolute, source) do
61
63
  Transformer.transform(source, path: absolute)
62
64
  end
63
65
 
@@ -96,7 +98,9 @@ module RSX
96
98
  # Reloads only the files whose contents changed. Used by the Rails reloader.
97
99
  def reload!
98
100
  @monitor.synchronize do
99
- @entries.values.each do |entry|
101
+ # values takes a snapshot, which each_value would not: the body reloads
102
+ # and deletes entries while iterating.
103
+ @entries.values.each do |entry| # rubocop:disable Style/HashEachMethods
100
104
  if !File.file?(entry.path)
101
105
  unload(entry)
102
106
  @entries.delete(entry.path)
@@ -128,21 +132,18 @@ module RSX
128
132
  end
129
133
 
130
134
  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
135
+ found = rsx_file_at(candidate)
136
+ return found if found && within_roots?(found)
137
137
  end
138
138
 
139
139
  nil
140
140
  end
141
141
 
142
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(", ")}")
143
+ resolved = resolve(spec, from: from)
144
+ return resolved if resolved
145
+
146
+ raise FileNotFoundError, unresolvable(spec, from)
146
147
  end
147
148
 
148
149
  def import(spec, as: nil, from: nil)
@@ -170,6 +171,40 @@ module RSX
170
171
 
171
172
  private
172
173
 
174
+ def rsx_file_at(candidate)
175
+ return candidate if candidate.end_with?(".rsx") && File.file?(candidate)
176
+
177
+ EXTENSIONS.each do |extension|
178
+ with_extension = "#{candidate}#{extension}"
179
+ return with_extension if File.file?(with_extension)
180
+ end
181
+
182
+ nil
183
+ end
184
+
185
+ # Loading a template evaluates it, so resolution stays inside the configured
186
+ # paths (plus the working directory, which is what scripts and the CLI point
187
+ # at). Without this a spec that came from a request could name any file on
188
+ # disk. Paths are compared after expansion, so `../` cannot climb out.
189
+ def within_roots?(path)
190
+ target = File.expand_path(path)
191
+ roots.any? { |root| target == root || target.start_with?("#{root}#{File::SEPARATOR}") }
192
+ end
193
+
194
+ def roots
195
+ Array(@config.paths).map { |root| File.expand_path(root.to_s) }.push(File.expand_path(Dir.pwd)).uniq
196
+ end
197
+
198
+ def unresolvable(spec, from)
199
+ searched = Array(@config.paths).join(", ")
200
+ if rsx_file_at(File.expand_path(spec.to_s, Dir.pwd)) || rsx_file_at(spec.to_s)
201
+ "`#{spec}` is outside the configured RSX paths, so it will not be loaded. " \
202
+ "Add its directory to RSX.config.paths. Configured: #{searched}"
203
+ else
204
+ "could not find `#{spec}`#{" imported from #{from}" if from}. Looked in: #{searched}"
205
+ end
206
+ end
207
+
173
208
  def mtime(path)
174
209
  File.mtime(path)
175
210
  rescue SystemCallError
@@ -189,6 +224,7 @@ module RSX
189
224
  entry.components.each { |component| RSX.remove_constant(component) }
190
225
  entry.components.clear
191
226
  entry.default = nil
227
+ RSX.discard_statics(entry.statics_prefix)
192
228
  end
193
229
  end
194
230
  end
data/lib/rsx/nodes.rb CHANGED
@@ -7,6 +7,9 @@ module RSX
7
7
  # Literal markup text, already whitespace-normalized JSX style.
8
8
  Text = Struct.new(:value, :line)
9
9
 
10
+ # The body of a raw text element (<script>, <style>), emitted byte for byte.
11
+ RawText = Struct.new(:value, :line)
12
+
10
13
  # {ruby} in child position.
11
14
  Expression = Struct.new(:source, :line)
12
15
 
data/lib/rsx/railtie.rb CHANGED
@@ -30,6 +30,7 @@ module RSX
30
30
  initializer "rsx.action_view" do
31
31
  ActiveSupport.on_load(:action_view) do
32
32
  ActionView::Template.register_template_handler(:rsx, RSX::TemplateHandler)
33
+ RSX::TemplateHandler.register_dependency_tracker
33
34
  include RSX::Helpers
34
35
  end
35
36
  end
data/lib/rsx/template.rb CHANGED
@@ -7,7 +7,7 @@ module RSX
7
7
  # template gets the whole component toolkit (children, caching, context,
8
8
  # helpers) and is compiled once no matter how often it is rendered.
9
9
  class Template
10
- attr_reader :path, :digest
10
+ attr_reader :path, :digest, :component
11
11
 
12
12
  def self.load(path)
13
13
  absolute = File.expand_path(path)
@@ -22,7 +22,7 @@ module RSX
22
22
 
23
23
  def initialize(source, path: nil)
24
24
  digest = RSX.config.compile_cache.digest(source.to_s)
25
- ruby = RSX.config.compile_cache.fetch(path || "template", source.to_s) do
25
+ ruby = RSX.config.compile_cache.fetch_or_compile(path || "template", source.to_s) do
26
26
  Transformer.transform(source.to_s, path: path)
27
27
  end
28
28
  build(ruby, path, digest)
@@ -40,10 +40,6 @@ module RSX
40
40
  end
41
41
  end
42
42
 
43
- def component
44
- @component
45
- end
46
-
47
43
  private
48
44
 
49
45
  def build(ruby, path, digest)
@@ -30,6 +30,38 @@ module RSX
30
30
  def handles_encoding?
31
31
  true
32
32
  end
33
+
34
+ # Rails builds a cache key for a template from the templates it renders, and
35
+ # finds those by compiling the template and looking for `render` calls in
36
+ # the result. Without a tracker for .rsx, a `cache` block wrapping an .rsx
37
+ # partial keeps serving the old markup after that partial changes.
38
+ #
39
+ # Since .rsx compiles to Ruby, Rails' own Ruby tracker needs nothing from
40
+ # us beyond being pointed at the extension.
41
+ # Call this after registering the handler: Rails keys trackers by handler,
42
+ # so the extension has to resolve to this one already.
43
+ def register_dependency_tracker
44
+ return false unless defined?(::ActionView)
45
+
46
+ require "action_view/dependency_tracker"
47
+ tracker = ruby_dependency_tracker
48
+ return false if tracker.nil?
49
+
50
+ ::ActionView::DependencyTracker.register_tracker(:rsx, tracker)
51
+ true
52
+ rescue ::LoadError
53
+ false
54
+ end
55
+
56
+ private
57
+
58
+ def ruby_dependency_tracker
59
+ %i[RubyTracker RipperTracker].each do |name|
60
+ registry = ::ActionView::DependencyTracker
61
+ return registry.const_get(name) if registry.const_defined?(name)
62
+ end
63
+ nil
64
+ end
33
65
  end
34
66
  end
35
67
  end
@@ -62,9 +62,16 @@ module RSX
62
62
  @pending_loop = false
63
63
  @jsx_spans = []
64
64
  @components = 0
65
- # Static markup slots are keyed by a digest of the source, so recompiling
66
- # the same file reuses the same slots instead of leaking new ones.
67
- @codegen = Codegen.new(path: path, prefix: "#{Digest::SHA256.hexdigest(@src)[0, 10]}-")
65
+ @preformatted = 0
66
+ @codegen = Codegen.new(path: path, prefix: self.class.static_prefix(@src))
67
+ end
68
+
69
+ # Static markup slots are keyed by a digest of the source, so recompiling the
70
+ # same file reuses its slots rather than adding another set. Deriving the
71
+ # prefix from the source alone also lets the loader find and drop the slots
72
+ # belonging to a version of a file it is replacing.
73
+ def self.static_prefix(source)
74
+ "#{Digest::SHA256.hexdigest(source.to_s)[0, 10]}-"
68
75
  end
69
76
 
70
77
  def transform
@@ -189,6 +196,7 @@ module RSX
189
196
  elsif @prev == :start && jsx_ahead?
190
197
  emit_jsx
191
198
  else
199
+ reject_misplaced_markup
192
200
  copy_operator
193
201
  end
194
202
  when "/"
@@ -237,8 +245,10 @@ module RSX
237
245
  def copy_operator
238
246
  char = peek
239
247
 
240
- # `::` and `?.`-like sequences are copied whole so state stays accurate.
241
- if char == ":" && peek(1) == ":"
248
+ # `::`, and a `<<` that heredoc_ahead? already ruled out as a heredoc, are
249
+ # copied whole so state stays accurate. Leaving the second character behind
250
+ # would put it in expression position, where `list <<x` reads as a tag.
251
+ if (char == ":" && peek(1) == ":") || (char == "<" && peek(1) == "<")
242
252
  copy(2)
243
253
  @prev = :start
244
254
  elsif char == ":" && symbol_ahead?
@@ -255,10 +265,10 @@ module RSX
255
265
 
256
266
  def copy_number
257
267
  copy while !eof? && /[0-9a-zA-Z_]/.match?(peek)
258
- if peek == "." && peek(1) && DIGIT.match?(peek(1))
259
- copy
260
- copy while !eof? && /[0-9a-zA-Z_]/.match?(peek)
261
- end
268
+ return unless peek == "." && peek(1) && DIGIT.match?(peek(1))
269
+
270
+ copy
271
+ copy while !eof? && /[0-9a-zA-Z_]/.match?(peek)
262
272
  end
263
273
 
264
274
  def copy_line_comment
@@ -363,8 +373,10 @@ module RSX
363
373
  char = peek
364
374
  case char
365
375
  when "\\" then copy(2)
366
- when "[" then in_class = true; copy
367
- when "]" then in_class = false; copy
376
+ when "[" then in_class = true
377
+ copy
378
+ when "]" then in_class = false
379
+ copy
368
380
  when "#"
369
381
  if peek(1) == "{"
370
382
  copy(2)
@@ -410,7 +422,9 @@ module RSX
410
422
  return
411
423
  end
412
424
  copy while !eof? && (IDENT_CHAR.match?(peek) || peek == "@" || peek == "$")
413
- copy if peek == "?" || peek == "!" || (peek == "=" && peek(1) != "=" && peek(1) != ">" && peek(1) != "~")
425
+ if peek == "?" || peek == "!" || (peek == "=" && peek(1) != "=" && peek(1) != ">" && peek(1) != "~")
426
+ copy
427
+ end
414
428
  end
415
429
 
416
430
  # ------------------------------------------------------------------
@@ -442,7 +456,7 @@ module RSX
442
456
  indented = peek == "~" || peek == "-"
443
457
  copy if indented
444
458
 
445
- quote = (peek == '"' || peek == "'" || peek == "`") ? peek : nil
459
+ quote = peek == '"' || peek == "'" || peek == "`" ? peek : nil
446
460
  copy if quote
447
461
  identifier = +""
448
462
  while !eof? && IDENT_CHAR.match?(peek)
@@ -533,11 +547,13 @@ module RSX
533
547
  if BLOCK_KEYWORDS.key?(word)
534
548
  @blocks.push({ kind: :block, line: @line })
535
549
  @prev = :start
550
+ # Stated ahead of OPENS_EXPRESSION so the precedence between the two
551
+ # tables is visible, even though the fallthrough agrees with it.
536
552
  elsif CLOSES_EXPRESSION.key?(word)
537
553
  @prev = :value
538
554
  elsif OPENS_EXPRESSION.key?(word)
539
555
  @prev = :start
540
- else
556
+ else # rubocop:disable Lint/DuplicateBranch
541
557
  @prev = :value
542
558
  end
543
559
  end
@@ -581,10 +597,12 @@ module RSX
581
597
  leftover.empty?
582
598
  end
583
599
 
600
+ METHOD_NAME_CHAR = %r{[A-Za-z0-9_.?!\[\]<>=+\-*/%&|^~]}
601
+
584
602
  def endless_def_ahead?
585
603
  offset = 0
586
604
  offset += 1 while @pos + offset < @len && /[ \t]/.match?(@src[@pos + offset])
587
- offset += 1 while @pos + offset < @len && /[A-Za-z0-9_.?!\[\]<>=+\-*\/%&|^~]/.match?(@src[@pos + offset])
605
+ offset += 1 while @pos + offset < @len && METHOD_NAME_CHAR.match?(@src[@pos + offset])
588
606
 
589
607
  if @src[@pos + offset] == "("
590
608
  depth = 0
@@ -619,7 +637,7 @@ module RSX
619
637
  lowercase = /\Acomponent[ \t]+([a-z_][A-Za-z0-9_]*)/.match(@src[@pos..])
620
638
  if lowercase
621
639
  error("component names must be constants, got `#{lowercase[1]}` " \
622
- "(try `component #{lowercase[1].split('_').map(&:capitalize).join}`)")
640
+ "(try `component #{lowercase[1].split("_").map(&:capitalize).join}`)")
623
641
  end
624
642
  return false
625
643
  end
@@ -654,17 +672,21 @@ module RSX
654
672
  until eof?
655
673
  char = peek
656
674
  case char
657
- when "(", "[", "{" then depth += 1; advance
658
- when ")", "]", "}" then depth -= 1; advance
675
+ when "(", "[", "{" then depth += 1
676
+ advance
677
+ when ")", "]", "}" then depth -= 1
678
+ advance
659
679
  when "'", '"'
660
680
  capture { copy_quoted(char) }
661
681
  when "#"
662
682
  advance until eof? || peek == "\n"
663
683
  when "d"
664
- if depth.zero? && lookahead(2) == "do" && !identifier_char?(peek(2)) && !identifier_char?(@src[@pos - 1])
684
+ if depth.zero? && lookahead(2) == "do" &&
685
+ !identifier_char?(peek(2)) && !identifier_char?(@src[@pos - 1])
665
686
  options = @src[start...@pos].strip
666
687
  advance(2)
667
688
  return "" if options.empty?
689
+
668
690
  return options.start_with?(",") ? options : ", #{options}"
669
691
  end
670
692
  advance
@@ -692,8 +714,10 @@ module RSX
692
714
  until eof?
693
715
  char = peek
694
716
  case char
695
- when "(", "[", "{" then depth += 1; advance
696
- when ")", "]", "}" then depth -= 1; advance
717
+ when "(", "[", "{" then depth += 1
718
+ advance
719
+ when ")", "]", "}" then depth -= 1
720
+ advance
697
721
  when "'", '"' then capture { copy_quoted(char) }
698
722
  when "|"
699
723
  if depth.zero?
@@ -760,6 +784,27 @@ module RSX
760
784
  !after.nil? && TAG_START.match?(after)
761
785
  end
762
786
 
787
+ # A tag that opens and closes on one line, used only to recognise a mistake.
788
+ MISPLACED_MARKUP = %r{\A<([A-Za-z][A-Za-z0-9_\-.:]*)(?:[ \t]*/>|[^<>\n]*>)}
789
+
790
+ # `render <div>x</div>` is not markup to Ruby, it is a chain of comparisons,
791
+ # because a value already ended the expression. Left alone it compiles to
792
+ # something that fails far from the real mistake, so name it here instead.
793
+ #
794
+ # The check demands a space before `<` and none after, which is how markup is
795
+ # written and how comparisons are not, so `a < b` and `a<b` never reach it.
796
+ def reject_misplaced_markup
797
+ return unless @prev == :value
798
+ return unless /[ \t]/.match?(@src[@pos - 1].to_s)
799
+
800
+ match = MISPLACED_MARKUP.match(@src[@pos..])
801
+ return if match.nil?
802
+ return unless match[0].end_with?("/>") || @src.index("</#{match[1]}>", @pos)
803
+
804
+ error("markup here needs parentheses: `method(<#{match[1]} ... />)`. Ruby reads a " \
805
+ "`<` that follows a value as a comparison, so the markup never starts.")
806
+ end
807
+
763
808
  def emit_jsx
764
809
  start_pos = @pos
765
810
  start_line = @line
@@ -796,13 +841,60 @@ module RSX
796
841
  advance
797
842
 
798
843
  # Void elements are complete at ">": HTML gives them no closing tag.
799
- return build_node(tag, attributes, [], true, line) if Attributes.void?(tag)
844
+ if Attributes.void?(tag)
845
+ reject_void_closing_tag(tag, line)
846
+ return build_node(tag, attributes, [], true, line)
847
+ end
848
+
849
+ children =
850
+ if Attributes.raw_text?(tag)
851
+ parse_raw_text(tag)
852
+ else
853
+ parse_element_children(tag)
854
+ end
800
855
 
801
- children = parse_children(tag)
802
856
  expect_closing_tag(tag)
803
857
  build_node(tag, attributes, children, false, line)
804
858
  end
805
859
 
860
+ def parse_element_children(tag)
861
+ @preformatted += 1 if Attributes.preformatted?(tag)
862
+ parse_children(tag)
863
+ ensure
864
+ @preformatted -= 1 if Attributes.preformatted?(tag)
865
+ end
866
+
867
+ # The body of <script> or <style>. HTML treats these as raw text, so nothing
868
+ # inside is markup: `.a > .b`, `if (a < b)` and JavaScript object literals are
869
+ # all just characters. Dynamic content goes through dangerouslySetInnerHTML.
870
+ def parse_raw_text(tag)
871
+ start = @pos
872
+ start_line = @line
873
+ closing = "</#{tag}"
874
+
875
+ until eof?
876
+ break if peek == "<" && lookahead(closing.length).to_s.casecmp?(closing)
877
+
878
+ advance
879
+ end
880
+
881
+ error("unterminated <#{tag}> element", line: start_line) if eof?
882
+
883
+ body = @src[start...@pos]
884
+ body.empty? ? [] : [Nodes::RawText.new(body, start_line)]
885
+ end
886
+
887
+ # `<br>text</br>` parses as a complete <br> followed by `text</br>`, which is
888
+ # then copied out as Ruby and fails in the generated file instead of here.
889
+ def reject_void_closing_tag(tag, line)
890
+ index = @src.index("<", @pos)
891
+ return if index.nil?
892
+ return unless %r{\A</#{Regexp.escape(tag)}[ \t]*>}i.match?(@src[index..])
893
+
894
+ error("<#{tag}> is a void element: it takes no children and has no closing tag. " \
895
+ "Write `<#{tag} />`.", line: line)
896
+ end
897
+
806
898
  def build_node(tag, attributes, children, self_closing, line)
807
899
  if tag == "Fragment" || tag == "React.Fragment" || tag == "RSX::Fragment"
808
900
  return Nodes::Fragment.new(children, line)
@@ -840,9 +932,7 @@ module RSX
840
932
  next
841
933
  end
842
934
 
843
- unless ATTR_START.match?(peek)
844
- error("unexpected `#{peek}` in <#{tag}> attributes")
845
- end
935
+ error("unexpected `#{peek}` in <#{tag}> attributes") unless ATTR_START.match?(peek)
846
936
 
847
937
  name = read_attribute_name
848
938
  skip_tag_whitespace
@@ -997,13 +1087,20 @@ module RSX
997
1087
  def flush_text(children, text, line)
998
1088
  return if text.empty?
999
1089
 
1000
- normalized = self.class.normalize_text(text)
1090
+ # Inside <pre> or <textarea> the browser shows whitespace as written, so
1091
+ # collapsing it the way JSX does elsewhere would change the output.
1092
+ if @preformatted.positive?
1093
+ children << Nodes::Text.new(text, line)
1094
+ return
1095
+ end
1096
+
1097
+ normalized = normalize_text(text)
1001
1098
  children << Nodes::Text.new(normalized, line) unless normalized.empty?
1002
1099
  end
1003
1100
 
1004
1101
  # JSX whitespace rules: indentation-only lines disappear, and remaining lines
1005
1102
  # are joined with a single space.
1006
- def self.normalize_text(raw)
1103
+ def normalize_text(raw)
1007
1104
  lines = raw.split("\n", -1)
1008
1105
  return lines.first.to_s if lines.length == 1
1009
1106
 
@@ -1028,7 +1125,7 @@ module RSX
1028
1125
  return if closing == tag
1029
1126
  return if tag == "" && closing == ""
1030
1127
 
1031
- error("closing tag `</#{closing}>` does not match opening tag `<#{tag.empty? ? '' : tag}>`")
1128
+ error("closing tag `</#{closing}>` does not match opening tag `<#{tag unless tag.empty?}>`")
1032
1129
  end
1033
1130
  end
1034
1131
  end
data/lib/rsx/version.rb CHANGED
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RSX
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
 
6
6
  # Bumping this invalidates every on-disk compile cache entry.
7
- COMPILER_VERSION = "1"
7
+ COMPILER_VERSION = "2"
8
8
  end
data/lib/rsx.rb CHANGED
@@ -31,7 +31,11 @@ module RSX
31
31
 
32
32
  # Slots for markup that never changes, populated on first render by compiled
33
33
  # templates. Keys are generated at compile time, one per call site.
34
- STATICS = {}
34
+ #
35
+ # Compiled markup reads a slot directly and only calls define_static on a miss,
36
+ # so the render path is a bare Hash read and the lock is paid once per slot.
37
+ STATICS = {} # rubocop:disable Style/MutableConstant
38
+ STATICS_LOCK = Mutex.new
35
39
 
36
40
  # Configuration is intentionally small: where to find components, where to put
37
41
  # compiled output, and which cache to use.
@@ -138,7 +142,7 @@ module RSX
138
142
  def precompile!(paths = config.paths)
139
143
  loader.files(paths).each do |file|
140
144
  source = File.read(file)
141
- config.compile_cache.fetch(file, source) { Transformer.transform(source, path: file) }
145
+ config.compile_cache.fetch_or_compile(file, source) { Transformer.transform(source, path: file) }
142
146
  end
143
147
  end
144
148
 
@@ -166,7 +170,7 @@ module RSX
166
170
 
167
171
  def render_file(path, context: nil, **props)
168
172
  entry = loader.load(loader.resolve!(path))
169
- props = props.empty? ? nil : props
173
+ props = nil if props.empty?
170
174
  component = entry.renderable
171
175
 
172
176
  if component
@@ -188,10 +192,9 @@ module RSX
188
192
 
189
193
  # Internal: emitted by compiled markup for every <Component /> tag.
190
194
  def render_component(target, props = nil, children = nil, parent = nil)
191
- if children
192
- props = props ? props : {}
193
- props[:children] = children
194
- end
195
+ # Copied rather than written into, so that a caller holding the hash does
196
+ # not find :children added to it.
197
+ props = props ? props.merge(children: children) : { children: children } if children
195
198
 
196
199
  case target
197
200
  when Proc
@@ -254,9 +257,19 @@ module RSX
254
257
  end
255
258
  end
256
259
 
257
- # Wraps markup that the compiler proved static. Called once per call site.
258
- def static(literal)
259
- SafeString.new(literal).freeze
260
+ # Internal: fills a static slot the first time its call site renders.
261
+ def define_static(key, literal)
262
+ STATICS_LOCK.synchronize { STATICS[key] ||= SafeString.new(literal).freeze }
263
+ end
264
+
265
+ # Internal: drops the slots belonging to one compiled version of a file.
266
+ # Editing a file in development compiles it under a fresh set of keys, and the
267
+ # old ones can never be reached again, so they would accumulate for the life
268
+ # of the process.
269
+ def discard_statics(prefix)
270
+ return if prefix.nil?
271
+
272
+ STATICS_LOCK.synchronize { STATICS.delete_if { |key, _| key.to_s.start_with?(prefix) } }
260
273
  end
261
274
 
262
275
  # Wraps an already-escaped or trusted string without copying when possible.
@@ -323,9 +336,7 @@ module RSX
323
336
  # Emitted by `export Name`.
324
337
  def export(component, from: nil)
325
338
  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
339
+ entry.components << component if entry && !entry.components.include?(component)
329
340
  component
330
341
  end
331
342
 
@@ -372,7 +383,9 @@ module RSX
372
383
  parts = name.split("::")
373
384
  target = Object
374
385
  parts[0..-2].each do |part|
375
- return unless target.const_defined?(part, false)
386
+ # Walking off the namespace means there is nothing to remove, which ends
387
+ # the method rather than the iteration.
388
+ return unless target.const_defined?(part, false) # rubocop:disable Lint/NonLocalExitFromIterator
376
389
 
377
390
  target = target.const_get(part, false)
378
391
  end
@@ -427,4 +440,4 @@ module RSX
427
440
  end
428
441
  end
429
442
 
430
- require_relative "rsx/railtie" if defined?(::Rails::Railtie)
443
+ require_relative "rsx/railtie" if defined?(Rails::Railtie)
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rsx-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
- - RSX contributors
8
- autorequire:
7
+ - Jason Brock
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-08-22 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies: []
13
12
  description: |
14
13
  RSX is a template language that brings React's JSX authoring model to Ruby.
@@ -18,6 +17,7 @@ description: |
18
17
  plain Ruby string building, so rendering is fast and cacheable. Zero runtime
19
18
  dependencies; Rails integration is optional and loads automatically when present.
20
19
  email:
20
+ - jasonallenbrock@gmail.com
21
21
  executables:
22
22
  - rsx
23
23
  extensions: []
@@ -34,6 +34,8 @@ files:
34
34
  - examples/user_profile.rsx
35
35
  - examples/views/dashboard.html.rsx
36
36
  - exe/rsx
37
+ - lib/generators/rsx/component/component_generator.rb
38
+ - lib/generators/rsx/component/templates/component.rsx.tt
37
39
  - lib/rsx-rb.rb
38
40
  - lib/rsx.rb
39
41
  - lib/rsx/attributes.rb
@@ -56,15 +58,14 @@ files:
56
58
  - lib/rsx/template_handler.rb
57
59
  - lib/rsx/transformer.rb
58
60
  - lib/rsx/version.rb
59
- homepage: https://github.com/rsx-rb/rsx
61
+ homepage: https://github.com/derwydd/rsx
60
62
  licenses:
61
63
  - MIT
62
64
  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
65
+ source_code_uri: https://github.com/derwydd/rsx
66
+ bug_tracker_uri: https://github.com/derwydd/rsx/issues
67
+ changelog_uri: https://github.com/derwydd/rsx/blob/main/CHANGELOG.md
66
68
  rubygems_mfa_required: 'true'
67
- post_install_message:
68
69
  rdoc_options: []
69
70
  require_paths:
70
71
  - lib
@@ -79,8 +80,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
79
80
  - !ruby/object:Gem::Version
80
81
  version: '0'
81
82
  requirements: []
82
- rubygems_version: 3.5.3
83
- signing_key:
83
+ rubygems_version: 4.0.20
84
84
  specification_version: 4
85
85
  summary: JSX-style templates for Ruby and Rails.
86
86
  test_files: []