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
data/README.md
ADDED
|
@@ -0,0 +1,1022 @@
|
|
|
1
|
+
# RSX — JSX for Ruby
|
|
2
|
+
|
|
3
|
+
RSX is a template language that brings React's JSX authoring model to Ruby. You write `.rsx`
|
|
4
|
+
files in which **Ruby replaces JavaScript** and markup is embedded directly in expression
|
|
5
|
+
position:
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
component Greeting do |name:, admin: false|
|
|
9
|
+
return (
|
|
10
|
+
<>
|
|
11
|
+
<h1 className="title">Hello, {name}!</h1>
|
|
12
|
+
{admin ? <p>Admin privileges active.</p> : <p>Standard account.</p>}
|
|
13
|
+
</>
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
export default Greeting
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
[Example: RSX with Tailwind CSS](https://github.com/derwydd/rsx-working-example)
|
|
21
|
+
|
|
22
|
+
Templates are compiled ahead of time into plain Ruby string building, so rendering is
|
|
23
|
+
concatenation and escaping — no interpreter, no virtual DOM, no diffing. RSX has **zero runtime
|
|
24
|
+
dependencies**; the Rails integration activates itself only when Rails is already loaded.
|
|
25
|
+
|
|
26
|
+
- [Installation](#installation)
|
|
27
|
+
- [Quick start](#quick-start)
|
|
28
|
+
- [Two kinds of `.rsx` file](#two-kinds-of-rsx-file)
|
|
29
|
+
- [The language](#the-language)
|
|
30
|
+
- [Attributes](#attributes)
|
|
31
|
+
- [Components](#components)
|
|
32
|
+
- [Context](#context)
|
|
33
|
+
- [Prerendering and caching](#prerendering-and-caching)
|
|
34
|
+
- [Rails integration](#rails-integration)
|
|
35
|
+
- [Using RSX without Rails](#using-rsx-without-rails)
|
|
36
|
+
- [Command line](#command-line)
|
|
37
|
+
- [Configuration reference](#configuration-reference)
|
|
38
|
+
- [Differences from React](#differences-from-react)
|
|
39
|
+
- [Errors and debugging](#errors-and-debugging)
|
|
40
|
+
- [Testing](#testing)
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Why RSX
|
|
45
|
+
|
|
46
|
+
Ruby's view layer has always been a string templating language with tags bolted on (`<%= %>`,
|
|
47
|
+
`= ` in Slim, indentation in Haml). JSX took the opposite approach: markup is a first-class
|
|
48
|
+
expression in the host language, so ordinary language constructs — variables, methods,
|
|
49
|
+
conditionals, loops, composition — are all you need to learn.
|
|
50
|
+
|
|
51
|
+
RSX is that model, in Ruby:
|
|
52
|
+
|
|
53
|
+
| Goal | How |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
| Familiar to anyone who knows JSX | Same syntax: `<>` fragments, `{}` containers, `className`, spread attributes, components as capitalized tags, `import`/`export default` |
|
|
56
|
+
| Fast enough for the request path | `.rsx` compiles to Ruby once; static markup collapses into single frozen literals |
|
|
57
|
+
| Cacheable like ViewComponent, without being ViewComponent | Whole-component caching, fragment caching, static prerendering, on-disk compile cache |
|
|
58
|
+
| Small surface area | No runtime dependencies; ~3k lines of Ruby; the compiler is a single-pass scanner |
|
|
59
|
+
| Debuggable | Generated Ruby preserves your line numbers, so backtraces point at the `.rsx` file |
|
|
60
|
+
|
|
61
|
+
### The example from React, ported
|
|
62
|
+
|
|
63
|
+
<table>
|
|
64
|
+
<tr><th>UserProfile.jsx</th><th>user_profile.rsx</th></tr>
|
|
65
|
+
<tr valign="top"><td>
|
|
66
|
+
|
|
67
|
+
```jsx
|
|
68
|
+
function UserProfile() {
|
|
69
|
+
const user = {
|
|
70
|
+
firstName: 'Jane',
|
|
71
|
+
lastName: 'Doe',
|
|
72
|
+
avatarUrl: '...',
|
|
73
|
+
isAdmin: true
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function formatName(n) {
|
|
77
|
+
return `${n.firstName} ${n.lastName}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const alertStyle = {
|
|
81
|
+
color: 'darkred',
|
|
82
|
+
backgroundColor: 'pink'
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<>
|
|
87
|
+
<h1>Welcome back, {formatName(user)}!</h1>
|
|
88
|
+
<img
|
|
89
|
+
src={user.avatarUrl}
|
|
90
|
+
alt="User profile picture"
|
|
91
|
+
className="profile-image"
|
|
92
|
+
/>
|
|
93
|
+
{user.isAdmin ? (
|
|
94
|
+
<p style={alertStyle}>Admin.</p>
|
|
95
|
+
) : (
|
|
96
|
+
<p>Standard user account.</p>
|
|
97
|
+
)}
|
|
98
|
+
</>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export default UserProfile;
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
</td><td>
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
component UserProfile do
|
|
109
|
+
user = {
|
|
110
|
+
first_name: "Jane",
|
|
111
|
+
last_name: "Doe",
|
|
112
|
+
avatar_url: "...",
|
|
113
|
+
is_admin: true
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
def format_name(n)
|
|
117
|
+
"#{n[:first_name]} #{n[:last_name]}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
alert_style = {
|
|
121
|
+
color: "darkred",
|
|
122
|
+
backgroundColor: "pink"
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<>
|
|
127
|
+
<h1>Welcome back, {format_name(user)}!</h1>
|
|
128
|
+
<img
|
|
129
|
+
src={user[:avatar_url]}
|
|
130
|
+
alt="User profile picture"
|
|
131
|
+
className="profile-image"
|
|
132
|
+
/>
|
|
133
|
+
{user[:is_admin] ? (
|
|
134
|
+
<p style={alert_style}>Admin.</p>
|
|
135
|
+
) : (
|
|
136
|
+
<p>Standard user account.</p>
|
|
137
|
+
)}
|
|
138
|
+
</>
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
export default UserProfile
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
</td></tr>
|
|
146
|
+
</table>
|
|
147
|
+
|
|
148
|
+
The full version lives in [`examples/user_profile.rsx`](examples/user_profile.rsx).
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Installation
|
|
153
|
+
|
|
154
|
+
Add the gem to your Gemfile:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
gem "rsx-rb"
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Then:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
bundle install
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Or install it directly:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
gem install rsx-rb
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The published gem is `rsx-rb` because `rsx` is already taken on RubyGems. The library is still `require "rsx"` — Bundler does that automatically.
|
|
173
|
+
|
|
174
|
+
RSX requires **Ruby 3.0+**. It has no runtime dependencies. In a Rails app — ActionView is the
|
|
175
|
+
only part RSX touches, and the suite runs against 7.1 — the railtie loads automatically and:
|
|
176
|
+
|
|
177
|
+
- registers the `.rsx` template handler with ActionView, so `app/views/**/*.html.rsx` just works;
|
|
178
|
+
- mixes `RSX::Helpers` into ActionView, giving you `rsx` and `rsx_file`;
|
|
179
|
+
- looks for components in `app/components` and `app/rsx`;
|
|
180
|
+
- caches compiled output in `tmp/cache/rsx`;
|
|
181
|
+
- eager loads components in production and reloads changed files in development;
|
|
182
|
+
- adds the `rsx:precompile`, `rsx:clear` and `rsx:components` rake tasks.
|
|
183
|
+
|
|
184
|
+
Nothing else is required. To change the defaults, see
|
|
185
|
+
[Configuration reference](#configuration-reference).
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Quick start
|
|
190
|
+
|
|
191
|
+
### 1. A view
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
# app/views/pages/home.html.rsx
|
|
195
|
+
<section className="hero">
|
|
196
|
+
<h1>{@title}</h1>
|
|
197
|
+
<p>Signed in as {current_user.email}</p>
|
|
198
|
+
{link_to "Docs", docs_path, class: "btn"}
|
|
199
|
+
</section>
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Render it from a controller exactly as you would an ERB view:
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
class PagesController < ApplicationController
|
|
206
|
+
def home
|
|
207
|
+
@title = "Welcome"
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Inside a view, `self` is the Rails view context: controller instance variables, `link_to`,
|
|
213
|
+
`form_with`, `t`, `render partial:` and every other helper are available directly.
|
|
214
|
+
|
|
215
|
+
### 2. A component
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
# app/components/badge.rsx
|
|
219
|
+
component Badge do |label:, tone: "neutral"|
|
|
220
|
+
return <span className={["badge", "badge-#{tone}"]}>{label}</span>
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
export default Badge
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Use it from any `.rsx` file by writing it as a tag:
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
# app/views/pages/home.html.rsx
|
|
230
|
+
import Badge from "badge"
|
|
231
|
+
|
|
232
|
+
<p>Status: <Badge label="Live" tone="success" /></p>
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
…or from ERB, Haml or Slim with the `rsx` helper:
|
|
236
|
+
|
|
237
|
+
```erb
|
|
238
|
+
<%= rsx Badge, label: "Live", tone: "success" %>
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
…or from anywhere in Ruby:
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
Badge.call(label: "Live") # => "<span class=\"badge badge-neutral\">Live</span>"
|
|
245
|
+
RSX.render(Badge, label: "Live") # same, and accepts context:
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## Two kinds of `.rsx` file
|
|
251
|
+
|
|
252
|
+
This is the only structural concept RSX adds, and it mirrors the difference between a JSX
|
|
253
|
+
*module* and a JSX *entry point*.
|
|
254
|
+
|
|
255
|
+
**A component file** contains one or more `component Name do ... end` declarations. It is
|
|
256
|
+
evaluated once, at the top level, exactly like a `.rb` file — so constants, `class`, `def` and
|
|
257
|
+
`require` behave normally. Components become constants you can reference anywhere.
|
|
258
|
+
|
|
259
|
+
```ruby
|
|
260
|
+
# app/components/alert.rsx
|
|
261
|
+
component Alert do |message:|
|
|
262
|
+
return <div className="alert" role="alert">{message}</div>
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
export default Alert
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
**A template file** contains markup at its top level and no component declarations. Its body is
|
|
269
|
+
compiled into a render method, so it can use `props` and — in Rails — the view context.
|
|
270
|
+
|
|
271
|
+
```ruby
|
|
272
|
+
# app/views/posts/show.html.rsx
|
|
273
|
+
<article>
|
|
274
|
+
<h1>{@post.title}</h1>
|
|
275
|
+
{@post.body}
|
|
276
|
+
</article>
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
RSX decides which is which by looking at the compiled output, so you never declare it. Both
|
|
280
|
+
kinds may `import` other files.
|
|
281
|
+
|
|
282
|
+
> Because the top level of a template file is Ruby, `{...}` there is a Ruby hash, not an
|
|
283
|
+
> expression container. Expression containers only exist *inside* markup. Write plain Ruby at
|
|
284
|
+
> the top level:
|
|
285
|
+
>
|
|
286
|
+
> ```ruby
|
|
287
|
+
> # not this: {@post ? <article /> : <p>None</p>}
|
|
288
|
+
> @post ? <article>{@post.title}</article> : <p>None</p>
|
|
289
|
+
> ```
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
## The language
|
|
294
|
+
|
|
295
|
+
Everything below is compiled at load time. There is no runtime template parsing.
|
|
296
|
+
|
|
297
|
+
### Markup in expression position
|
|
298
|
+
|
|
299
|
+
A `<` begins markup wherever Ruby expects a value: after `return`, `=`, `(`, `,`, `&&`, inside a
|
|
300
|
+
block, and so on. Everywhere else `<` stays a Ruby operator, so `a < b`, `a << b`, `a <=> b`,
|
|
301
|
+
`class Foo < Bar` and heredocs (`<<~SQL`) are untouched.
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
title = <h1>Hi</h1> # assignment
|
|
305
|
+
rows = items.map { |i| <li>{i}</li> } # block body
|
|
306
|
+
return <p>{n < 10 ? "few" : "many"}</p> # comparison inside a container
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### Fragments
|
|
310
|
+
|
|
311
|
+
Multiple sibling elements need one parent. Use `<>...</>` when you do not want a wrapper
|
|
312
|
+
element (`<Fragment>` and `<React.Fragment>` are accepted too):
|
|
313
|
+
|
|
314
|
+
```ruby
|
|
315
|
+
return (
|
|
316
|
+
<>
|
|
317
|
+
<dt>Term</dt>
|
|
318
|
+
<dd>Definition</dd>
|
|
319
|
+
</>
|
|
320
|
+
)
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Expression containers
|
|
324
|
+
|
|
325
|
+
`{}` inside markup interpolates any Ruby expression. Values are escaped unless already marked
|
|
326
|
+
safe. Following React: `nil`, `true` and `false` render nothing, arrays are concatenated, and
|
|
327
|
+
everything else is converted with `to_s`.
|
|
328
|
+
|
|
329
|
+
```ruby
|
|
330
|
+
<p>{user.name}</p>
|
|
331
|
+
<p>{format("%.2f", total)}</p>
|
|
332
|
+
<p>{items.sum { |i| i.price }}</p>
|
|
333
|
+
<p>{"admin" if user.admin?}</p>
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### Conditionals
|
|
337
|
+
|
|
338
|
+
Any Ruby conditional works, because a container holds an expression:
|
|
339
|
+
|
|
340
|
+
```ruby
|
|
341
|
+
<div>
|
|
342
|
+
{user.admin? ? <Admin /> : <Standard />} {/* ternary, as in JSX */}
|
|
343
|
+
{user.admin? && <p>Danger zone</p>} {/* && shortcut, as in JSX */}
|
|
344
|
+
{notice.presence && <Alert message={notice} />}
|
|
345
|
+
|
|
346
|
+
{if user.admin? {/* or an if/else expression */}
|
|
347
|
+
<Admin />
|
|
348
|
+
elsif user.staff?
|
|
349
|
+
<Staff />
|
|
350
|
+
else
|
|
351
|
+
<Standard />
|
|
352
|
+
end}
|
|
353
|
+
|
|
354
|
+
{case status
|
|
355
|
+
when :ok then <Ok />
|
|
356
|
+
when :error then <Err />
|
|
357
|
+
end}
|
|
358
|
+
</div>
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Lists
|
|
362
|
+
|
|
363
|
+
Return markup from any enumerable method. `key` is accepted (for parity with JSX) and not
|
|
364
|
+
rendered:
|
|
365
|
+
|
|
366
|
+
```ruby
|
|
367
|
+
<ul>
|
|
368
|
+
{users.map { |user| <li key={user.id}>{user.name}</li> }}
|
|
369
|
+
</ul>
|
|
370
|
+
|
|
371
|
+
<tbody>
|
|
372
|
+
{rows.each_with_index.map do |row, index|
|
|
373
|
+
<tr className={index.even? ? "even" : "odd"}>
|
|
374
|
+
<td>{row.label}</td>
|
|
375
|
+
</tr>
|
|
376
|
+
end}
|
|
377
|
+
</tbody>
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
### Comments
|
|
381
|
+
|
|
382
|
+
`{/* ... */}` inside markup is removed at compile time, and Ruby `#` comments work in Ruby
|
|
383
|
+
position (including inside a tag's attribute list):
|
|
384
|
+
|
|
385
|
+
```ruby
|
|
386
|
+
<div>
|
|
387
|
+
{/* not emitted */}
|
|
388
|
+
<img
|
|
389
|
+
src={url}
|
|
390
|
+
alt="" # a Ruby comment, also fine here
|
|
391
|
+
/>
|
|
392
|
+
</div>
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
### Text and whitespace
|
|
396
|
+
|
|
397
|
+
RSX applies JSX's whitespace rules: indentation-only lines are dropped and remaining lines are
|
|
398
|
+
joined with a single space. So this…
|
|
399
|
+
|
|
400
|
+
```ruby
|
|
401
|
+
<p>
|
|
402
|
+
Hello,
|
|
403
|
+
world
|
|
404
|
+
</p>
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
…renders `<p>Hello, world</p>`. Use `{" "}` when you need a space JSX would have collapsed.
|
|
408
|
+
|
|
409
|
+
### Escaping and raw HTML
|
|
410
|
+
|
|
411
|
+
Interpolated values are HTML-escaped. Strings already marked safe (RSX's own output, and
|
|
412
|
+
anything answering `html_safe?`, such as `ActiveSupport::SafeBuffer`) pass through untouched.
|
|
413
|
+
|
|
414
|
+
```ruby
|
|
415
|
+
<p>{"<b>"}</p> # => <p><b></p>
|
|
416
|
+
<p>{raw("<b>bold</b>")}</p> # => <p><b>bold</b></p>
|
|
417
|
+
<div dangerouslySetInnerHTML={{ __html: markdown }} /> # React's escape hatch
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
---
|
|
421
|
+
|
|
422
|
+
## Attributes
|
|
423
|
+
|
|
424
|
+
Attribute values are either a quoted string or a `{ruby}` container. As in JSX, never both
|
|
425
|
+
(`className="{x}"` is a literal string).
|
|
426
|
+
|
|
427
|
+
### Names
|
|
428
|
+
|
|
429
|
+
React's prop spellings are translated to HTML: `className` → `class`, `htmlFor` → `for`,
|
|
430
|
+
`tabIndex` → `tabindex`, `httpEquiv` → `http-equiv`, `strokeWidth` → `stroke-width`, and so on.
|
|
431
|
+
Case-sensitive SVG attributes (`viewBox`, `preserveAspectRatio`, …) keep their spelling. Names
|
|
432
|
+
that are already lowercase, `snake_case` or `kebab-case` pass through unchanged, so
|
|
433
|
+
`data-controller="modal"` works as written.
|
|
434
|
+
|
|
435
|
+
```ruby
|
|
436
|
+
<label htmlFor="email" className="lbl">Email</label>
|
|
437
|
+
# => <label for="email" class="lbl">Email</label>
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### `className`
|
|
441
|
+
|
|
442
|
+
Accepts a String, Symbol, Array or Hash, and flattens nested combinations. Hash keys are
|
|
443
|
+
included when their value is truthy:
|
|
444
|
+
|
|
445
|
+
```ruby
|
|
446
|
+
<div className={["card", size, { selected: selected?, "is-new" => new? }]}></div>
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
### `style`
|
|
450
|
+
|
|
451
|
+
Accepts a String or a Hash of CSS properties. `camelCase` and `snake_case` keys become
|
|
452
|
+
`kebab-case`, and numbers get `px` unless the property is unitless (`z-index`, `line-height`,
|
|
453
|
+
`opacity`, `flex-grow`, …):
|
|
454
|
+
|
|
455
|
+
```ruby
|
|
456
|
+
<p style={{ backgroundColor: "pink", marginTop: 8, zIndex: 3 }}></p>
|
|
457
|
+
# => <p style="background-color:pink;margin-top:8px;z-index:3"></p>
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
### Booleans
|
|
461
|
+
|
|
462
|
+
HTML boolean attributes are rendered bare when truthy and dropped when falsy. Non-boolean
|
|
463
|
+
attributes given `true` render `="true"`:
|
|
464
|
+
|
|
465
|
+
```ruby
|
|
466
|
+
<input type="checkbox" checked disabled={false} required={true} />
|
|
467
|
+
# => <input type="checkbox" checked required>
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
### `data` and `aria`
|
|
471
|
+
|
|
472
|
+
Pass a Hash to expand it into prefixed attributes. Arrays and Hashes are serialized as JSON.
|
|
473
|
+
Following React, booleans become the strings `"true"`/`"false"`, and `nil` drops the attribute:
|
|
474
|
+
|
|
475
|
+
```ruby
|
|
476
|
+
<div data={{ user_id: 7, ids: [1, 2] }} aria={{ label: "Close", hidden: true }}></div>
|
|
477
|
+
# => <div data-user-id="7" data-ids="[1,2]" aria-label="Close" aria-hidden="true"></div>
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
### Spread
|
|
481
|
+
|
|
482
|
+
Both the JSX and the Ruby spelling are accepted:
|
|
483
|
+
|
|
484
|
+
```ruby
|
|
485
|
+
<a {...attrs} className="link">x</a>
|
|
486
|
+
<a {**attrs} className="link">x</a>
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
Attributes on an element with a spread are merged the way React merges props: names that map to
|
|
490
|
+
the same HTML attribute collapse, keeping the last value. So `className="link"` above overrides
|
|
491
|
+
a `class` or `className` coming from `attrs`, rather than emitting the attribute twice. A `nil`
|
|
492
|
+
or `false` spread contributes nothing.
|
|
493
|
+
|
|
494
|
+
Spread works on components too, where it becomes keyword arguments.
|
|
495
|
+
|
|
496
|
+
### Event handlers
|
|
497
|
+
|
|
498
|
+
There is no client-side runtime, so handlers are strings — the value of an HTML attribute:
|
|
499
|
+
|
|
500
|
+
```ruby
|
|
501
|
+
<button onClick={"openSettings()"}>Settings</button>
|
|
502
|
+
# => <button onclick="openSettings()">Settings</button>
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
For real interactivity, use the attributes your JS framework expects
|
|
506
|
+
(`data-controller`, `data-action`, `hx-post`, …) — they pass through untouched.
|
|
507
|
+
|
|
508
|
+
### Void and self-closing elements
|
|
509
|
+
|
|
510
|
+
Void elements never get a closing tag, whether or not you write `/`:
|
|
511
|
+
|
|
512
|
+
```ruby
|
|
513
|
+
<br /> # => <br>
|
|
514
|
+
<img src={u}> # => <img src="...">
|
|
515
|
+
<circle r={4} /> # SVG keeps XML self-closing syntax => <circle r="4"/>
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
---
|
|
519
|
+
|
|
520
|
+
## Components
|
|
521
|
+
|
|
522
|
+
### Defining
|
|
523
|
+
|
|
524
|
+
```ruby
|
|
525
|
+
component Name[, options] do |parameters|
|
|
526
|
+
...
|
|
527
|
+
return <markup />
|
|
528
|
+
end
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
The block body becomes the component's render method, so `return` is optional but reads well
|
|
532
|
+
with a parenthesized markup block. Component names must be constants; nesting is supported
|
|
533
|
+
(`component Admin::Panel do`).
|
|
534
|
+
|
|
535
|
+
### Props
|
|
536
|
+
|
|
537
|
+
Declare props as **keyword parameters** — required, optional with defaults, or collected:
|
|
538
|
+
|
|
539
|
+
```ruby
|
|
540
|
+
component Button do |label:, variant: "primary", disabled: false, **rest|
|
|
541
|
+
return <button className={["btn", "btn-#{variant}"]} disabled={disabled} {**rest}>{label}</button>
|
|
542
|
+
end
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
- `label:` is required. Omitting it raises `RSX::PropsError` naming the component.
|
|
546
|
+
- `variant:` has a default.
|
|
547
|
+
- `**rest` collects anything else, so callers can add `id`, `data-*` or `aria` attributes
|
|
548
|
+
without the component knowing about them.
|
|
549
|
+
- Without `**rest`, passing an undeclared prop raises `RSX::PropsError` listing what *is*
|
|
550
|
+
declared. This is RSX's substitute for `propTypes`: mistakes surface immediately.
|
|
551
|
+
|
|
552
|
+
For a component that just forwards everything, take a single positional parameter and read the
|
|
553
|
+
props hash:
|
|
554
|
+
|
|
555
|
+
```ruby
|
|
556
|
+
component Debug do |props|
|
|
557
|
+
return <pre>{props.inspect}</pre>
|
|
558
|
+
end
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
### Children
|
|
562
|
+
|
|
563
|
+
Markup nested inside a component tag arrives as the `children:` prop:
|
|
564
|
+
|
|
565
|
+
```ruby
|
|
566
|
+
component Card do |title:, children: nil|
|
|
567
|
+
return (
|
|
568
|
+
<section className="card">
|
|
569
|
+
<h2>{title}</h2>
|
|
570
|
+
<div className="card-body">{children}</div>
|
|
571
|
+
</section>
|
|
572
|
+
)
|
|
573
|
+
end
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
```ruby
|
|
577
|
+
<Card title="Hello">
|
|
578
|
+
<p>Anything at all.</p>
|
|
579
|
+
</Card>
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
Children are **lazy**: they are rendered when interpolated, not when passed. That is what makes
|
|
583
|
+
context providers, caching and conditional slots work correctly. `children?` tells you whether
|
|
584
|
+
any were given:
|
|
585
|
+
|
|
586
|
+
```ruby
|
|
587
|
+
component Panel do |children: nil|
|
|
588
|
+
return <div>{children? ? children : <p className="empty">Nothing here</p>}</div>
|
|
589
|
+
end
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
### Slots
|
|
593
|
+
|
|
594
|
+
A slot is just a prop holding markup, so no extra API is needed:
|
|
595
|
+
|
|
596
|
+
```ruby
|
|
597
|
+
<Card title="Report" footer={<a href="/export">Export</a>}>
|
|
598
|
+
<Chart data={@data} />
|
|
599
|
+
</Card>
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
### Render props
|
|
603
|
+
|
|
604
|
+
If the only child is an expression, it is passed through unrendered — so a lambda child becomes
|
|
605
|
+
a render prop, as in React:
|
|
606
|
+
|
|
607
|
+
```ruby
|
|
608
|
+
component List do |items:, children: nil|
|
|
609
|
+
return <ul>{items.map { |item| <li>{children.call(item)}</li> }}</ul>
|
|
610
|
+
end
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
```ruby
|
|
614
|
+
<List items={@users}>
|
|
615
|
+
{->(user) { <a href={user_path(user)}>{user.name}</a> }}
|
|
616
|
+
</List>
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
### Composition, `import` and `export`
|
|
620
|
+
|
|
621
|
+
Files reference each other with JSX's module syntax. Paths are resolved against the configured
|
|
622
|
+
paths (and relative to the importing file), with the `.rsx` and `.html.rsx` extensions optional:
|
|
623
|
+
|
|
624
|
+
```ruby
|
|
625
|
+
import Button from "components/button" # default export, bound to `Button`
|
|
626
|
+
import { Card, CardList } from "components/card"
|
|
627
|
+
import "components/registers_many_components" # load for side effects
|
|
628
|
+
|
|
629
|
+
component Toolbar do |props|
|
|
630
|
+
return <div><Button label="Save" /><Card title="Recent" /></div>
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
export default Toolbar # what `import X from "..."` binds
|
|
634
|
+
export Toolbar # also part of this file's public list
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
Components are plain constants, so `import` is a convenience, not a requirement: anything
|
|
638
|
+
already loaded (in Rails, everything under the configured paths) can be used by name. Dotted and
|
|
639
|
+
namespaced tags work too: `<Admin::Panel />`, `<Layout.Header />`.
|
|
640
|
+
|
|
641
|
+
Any object that responds to `rsx_call(props, parent)` can be rendered as a tag, and a `Proc` can
|
|
642
|
+
be used as a component:
|
|
643
|
+
|
|
644
|
+
```ruby
|
|
645
|
+
Spacer = ->(props) { <hr className="spacer" /> }
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
### Rendering from Ruby
|
|
649
|
+
|
|
650
|
+
```ruby
|
|
651
|
+
Badge.call(label: "Live") # keyword props
|
|
652
|
+
Badge.render(label: "Live") # alias
|
|
653
|
+
RSX.render(Badge, label: "Live") # component, lambda, or name
|
|
654
|
+
RSX.render("components/badge", label: "x")# a file path
|
|
655
|
+
RSX.render_file("app/views/x.html.rsx") # a template file
|
|
656
|
+
RSX.render_source("<p>{props[:a]}</p>", a: 1) # source, handy in tests
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
All of them return an `RSX::SafeString`, which reports `html_safe?` and escapes anything unsafe
|
|
660
|
+
concatenated onto it.
|
|
661
|
+
|
|
662
|
+
---
|
|
663
|
+
|
|
664
|
+
## Context
|
|
665
|
+
|
|
666
|
+
React's Context API, for values that would otherwise be threaded through every component:
|
|
667
|
+
|
|
668
|
+
```ruby
|
|
669
|
+
# app/components/theme.rsx
|
|
670
|
+
Theme = RSX.create_context("light", name: "Theme")
|
|
671
|
+
|
|
672
|
+
component ThemedPanel do |children: nil|
|
|
673
|
+
theme = use_context(Theme)
|
|
674
|
+
return <div className={["panel", "panel-#{theme}"]}>{children}</div>
|
|
675
|
+
end
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
```ruby
|
|
679
|
+
<Theme.Provider value={"dark"}>
|
|
680
|
+
<ThemedPanel>Rendered dark, however deep it is nested.</ThemedPanel>
|
|
681
|
+
</Theme.Provider>
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
Provided values live on a per-thread stack and are popped when the provider finishes, so
|
|
685
|
+
concurrent requests never observe each other's context. Outside any provider, `use_context`
|
|
686
|
+
returns the default. You can also push a value from plain Ruby:
|
|
687
|
+
|
|
688
|
+
```ruby
|
|
689
|
+
Theme.with("dark") { render_something }
|
|
690
|
+
Theme.value # => "light" again
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
---
|
|
694
|
+
|
|
695
|
+
## Prerendering and caching
|
|
696
|
+
|
|
697
|
+
RSX is built so that a request pays for as little as possible. There are four layers, from
|
|
698
|
+
cheapest to most general.
|
|
699
|
+
|
|
700
|
+
### 1. Compilation happens before the request
|
|
701
|
+
|
|
702
|
+
`.rsx` is transformed into Ruby once and cached on disk (`tmp/cache/rsx` in Rails), keyed by a
|
|
703
|
+
digest of the source and the compiler version. Warm it at deploy time:
|
|
704
|
+
|
|
705
|
+
```bash
|
|
706
|
+
bin/rails rsx:precompile
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
In production the railtie also eager loads every component at boot, so no request ever compiles
|
|
710
|
+
a template. `RSX.precompile!` does the same thing outside of rake.
|
|
711
|
+
|
|
712
|
+
### 2. Static markup collapses into one frozen literal
|
|
713
|
+
|
|
714
|
+
Markup with no interpolation becomes a single string, allocated once per call site and reused
|
|
715
|
+
for the life of the process:
|
|
716
|
+
|
|
717
|
+
```ruby
|
|
718
|
+
# source
|
|
719
|
+
<div className="card"><h1>Hi</h1></div>
|
|
720
|
+
|
|
721
|
+
# compiled
|
|
722
|
+
(::RSX::STATICS[:"1c4f972a7c-1"] ||= ::RSX.static("<div class=\"card\"><h1>Hi</h1></div>"))
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
Dynamic markup keeps its static parts inline, so there is one string build and no intermediate
|
|
726
|
+
objects per element:
|
|
727
|
+
|
|
728
|
+
```ruby
|
|
729
|
+
# source
|
|
730
|
+
<p>{name}</p>
|
|
731
|
+
|
|
732
|
+
# compiled
|
|
733
|
+
::RSX::SafeString.new("<p>#{::RSX.child((name))}</p>")
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
### 3. Static components render once
|
|
737
|
+
|
|
738
|
+
When a component's body is nothing but static markup, the compiler marks it and its output is
|
|
739
|
+
memoized after the first render. This is automatic; `static: true` states it explicitly:
|
|
740
|
+
|
|
741
|
+
```ruby
|
|
742
|
+
component Divider, static: true do
|
|
743
|
+
return <hr className="rule" />
|
|
744
|
+
end
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
### 4. Component and fragment caching
|
|
748
|
+
|
|
749
|
+
Cache a whole component, keyed by its props plus a digest of its source file (so editing the
|
|
750
|
+
component invalidates its entries):
|
|
751
|
+
|
|
752
|
+
```ruby
|
|
753
|
+
component Sidebar, cache: { expires_in: 300 } do |section:|
|
|
754
|
+
...
|
|
755
|
+
end
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
`cache:` accepts `true`, a number of seconds, a Hash of `expires_in:`/`key:`, or a lambda used
|
|
759
|
+
as the key:
|
|
760
|
+
|
|
761
|
+
```ruby
|
|
762
|
+
component UserCard, cache: { key: ->(props) { [props[:user], I18n.locale] }, expires_in: 1.hour } do |user:|
|
|
763
|
+
...
|
|
764
|
+
end
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
Cache just the expensive part of a body with the `cache` helper:
|
|
768
|
+
|
|
769
|
+
```ruby
|
|
770
|
+
component Page do |user:|
|
|
771
|
+
return (
|
|
772
|
+
<div>
|
|
773
|
+
<h1>{user.name}</h1>
|
|
774
|
+
{cache(["stats", user], expires_in: 60) do
|
|
775
|
+
<ExpensiveStats user={user} />
|
|
776
|
+
end}
|
|
777
|
+
</div>
|
|
778
|
+
)
|
|
779
|
+
end
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
Cache keys are built from any Ruby value, using `cache_key_with_version` / `cache_key` /
|
|
783
|
+
`id`+`updated_at` when available — so passing an ActiveRecord model does the right thing.
|
|
784
|
+
|
|
785
|
+
### Cache stores
|
|
786
|
+
|
|
787
|
+
The default store is a thread-safe in-process LRU (`RSX::Cache::Memory`), which needs no
|
|
788
|
+
configuration. In Rails, point RSX at `Rails.cache` to share invalidation with the rest of the
|
|
789
|
+
app:
|
|
790
|
+
|
|
791
|
+
```ruby
|
|
792
|
+
config.rsx.cache_store = :rails # or :memory, :null, or any object with fetch/read/write/clear
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
Anything responding to `fetch(key, expires_in:) { }`, `read`, `write`, `delete` and `clear`
|
|
796
|
+
qualifies, so Redis or Memcached need no adapter.
|
|
797
|
+
|
|
798
|
+
---
|
|
799
|
+
|
|
800
|
+
## Rails integration
|
|
801
|
+
|
|
802
|
+
### Views, partials and layouts
|
|
803
|
+
|
|
804
|
+
Any view, partial or layout can be `.html.rsx`. Inside one, `self` is the view context:
|
|
805
|
+
|
|
806
|
+
```ruby
|
|
807
|
+
# app/views/posts/show.html.rsx
|
|
808
|
+
<article className="post">
|
|
809
|
+
<h1>{@post.title}</h1>
|
|
810
|
+
{render(partial: "posts/byline", locals: { author: @post.author })}
|
|
811
|
+
<footer>{link_to "All posts", posts_path}</footer>
|
|
812
|
+
</article>
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
```ruby
|
|
816
|
+
# app/views/posts/_byline.html.rsx
|
|
817
|
+
<p className="byline" data={{ author_id: author.id }}>{author.name}</p>
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
Partial locals are local variables, exactly as in ERB. Output is html-safe, so `.rsx` and ERB
|
|
821
|
+
templates can render each other freely.
|
|
822
|
+
|
|
823
|
+
### Components from ERB, Haml or Slim
|
|
824
|
+
|
|
825
|
+
```erb
|
|
826
|
+
<%= rsx Badge, label: "Live" %>
|
|
827
|
+
<%= rsx "Badge", label: "Live" %> <%# by name, autoloaded on demand %>
|
|
828
|
+
|
|
829
|
+
<%= rsx Card, title: "Hello" do %>
|
|
830
|
+
<p>This ERB block becomes the component's children.</p>
|
|
831
|
+
<% end %>
|
|
832
|
+
|
|
833
|
+
<%= rsx_file "views/marketing/hero", plan: @plan %>
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
### Rails helpers from inside a component
|
|
837
|
+
|
|
838
|
+
Components are not views, so they get the view context explicitly through `helpers` (aliased
|
|
839
|
+
`view_context`) — the nearest non-component ancestor:
|
|
840
|
+
|
|
841
|
+
```ruby
|
|
842
|
+
component PostLink do |post:|
|
|
843
|
+
return <a href={helpers.post_path(post)}>{post.title}</a>
|
|
844
|
+
end
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
`helpers?` reports whether one is available. When you render a component outside a request, pass
|
|
848
|
+
one in: `RSX.render(PostLink, context: view, post: post)`.
|
|
849
|
+
|
|
850
|
+
### Configuration
|
|
851
|
+
|
|
852
|
+
```ruby
|
|
853
|
+
# config/application.rb
|
|
854
|
+
config.rsx.paths = [Rails.root.join("app/components"), Rails.root.join("app/rsx")]
|
|
855
|
+
config.rsx.cache_store = :rails
|
|
856
|
+
config.rsx.cache_dir = Rails.root.join("tmp/cache/rsx")
|
|
857
|
+
config.rsx.component_namespace = Object # e.g. Components to namespace every component
|
|
858
|
+
config.rsx.reload = !Rails.env.production?
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
Directories on `config.rsx.paths` are added to Rails' file watcher, so editing a component in
|
|
862
|
+
development reloads just that file.
|
|
863
|
+
|
|
864
|
+
### Rake tasks
|
|
865
|
+
|
|
866
|
+
```bash
|
|
867
|
+
bin/rails rsx:precompile # compile every .rsx file and warm the on-disk cache
|
|
868
|
+
bin/rails rsx:clear # delete compiled output and clear the render cache
|
|
869
|
+
bin/rails rsx:components # list every file and the components it defines
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
---
|
|
873
|
+
|
|
874
|
+
## Using RSX without Rails
|
|
875
|
+
|
|
876
|
+
RSX is a plain Ruby library; nothing above requires Rails.
|
|
877
|
+
|
|
878
|
+
```ruby
|
|
879
|
+
require "rsx"
|
|
880
|
+
|
|
881
|
+
RSX.configure do |config|
|
|
882
|
+
config.paths = ["components"]
|
|
883
|
+
config.cache_dir = "tmp/rsx" # nil to compile in memory only
|
|
884
|
+
config.cache_store = RSX::Cache::Memory.new
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
RSX.load("components/badge.rsx") # or RSX.load_all
|
|
888
|
+
puts Badge.call(label: "Live")
|
|
889
|
+
puts RSX.render_file("pages/index.rsx", title: "Home")
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
In Sinatra, Roda or Rack, `RSX.render_file(path, context: self, **props)` is usually all you
|
|
893
|
+
need — the context object is what `helpers` returns inside components.
|
|
894
|
+
|
|
895
|
+
---
|
|
896
|
+
|
|
897
|
+
## Command line
|
|
898
|
+
|
|
899
|
+
```bash
|
|
900
|
+
rsx compile app/components/button.rsx # print the Ruby a file compiles to
|
|
901
|
+
rsx render app/views/home.html.rsx -p title=Hi
|
|
902
|
+
rsx precompile app # warm the on-disk compile cache
|
|
903
|
+
rsx version
|
|
904
|
+
```
|
|
905
|
+
|
|
906
|
+
Options: `-I/--include PATH` adds a directory to the load path, `-c/--cache-dir DIR` chooses
|
|
907
|
+
where compiled output goes, `-p/--prop NAME=VALUE` passes a string prop.
|
|
908
|
+
|
|
909
|
+
`rsx compile` is the fastest way to understand what RSX is doing — the output is ordinary Ruby.
|
|
910
|
+
|
|
911
|
+
---
|
|
912
|
+
|
|
913
|
+
## Configuration reference
|
|
914
|
+
|
|
915
|
+
| Setting | Default | Meaning |
|
|
916
|
+
| --- | --- | --- |
|
|
917
|
+
| `paths` | `app/components`, `app/rsx` (Rails) | Directories searched for `.rsx` files and imports |
|
|
918
|
+
| `cache_dir` | `tmp/cache/rsx` | Where compiled Ruby is stored; `nil` compiles in memory |
|
|
919
|
+
| `cache_store` | `RSX::Cache::Memory` | Store for component and fragment caches |
|
|
920
|
+
| `component_namespace` | `Object` | Module that `component Name` constants are defined under |
|
|
921
|
+
| `reload` | `true` outside production | Reload changed `.rsx` files between requests |
|
|
922
|
+
|
|
923
|
+
Useful entry points on the `RSX` module: `compile`, `load`, `load_all`, `reload!`,
|
|
924
|
+
`precompile!`, `render`, `render_file`, `render_source`, `template`, `lookup_component`,
|
|
925
|
+
`create_context`, `cache`, `config`, `configure`, `reset!`.
|
|
926
|
+
|
|
927
|
+
---
|
|
928
|
+
|
|
929
|
+
## Differences from React
|
|
930
|
+
|
|
931
|
+
RSX mirrors JSX's *authoring* model completely. It is not a client-side framework, so the
|
|
932
|
+
runtime differences are worth stating plainly:
|
|
933
|
+
|
|
934
|
+
- **Server-side only.** There is no state, no hooks, no effects, no re-rendering. A component is
|
|
935
|
+
a function from props to HTML. `useState`, `useEffect` and friends have no analogue.
|
|
936
|
+
- **Event handlers are strings**, not functions: `onClick={"submit()"}` becomes an `onclick`
|
|
937
|
+
attribute. Pair RSX with Hotwire, Stimulus, htmx or Alpine for behavior.
|
|
938
|
+
- **`key` is accepted and ignored.** There is no reconciliation to help.
|
|
939
|
+
- **Ruby, not JavaScript**, inside `{}` — so `user[:name]` rather than `user.name` for hashes,
|
|
940
|
+
`&&`/`||` semantics differ around `0` and `""`, and `nil` replaces `null`/`undefined`.
|
|
941
|
+
- **Expression containers only exist inside markup.** At the top level of a template file, `{}`
|
|
942
|
+
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.
|
|
946
|
+
|
|
947
|
+
---
|
|
948
|
+
|
|
949
|
+
## Errors and debugging
|
|
950
|
+
|
|
951
|
+
Compiled Ruby preserves the line numbers of the original `.rsx` file, so exceptions raised while
|
|
952
|
+
rendering point at the source you wrote:
|
|
953
|
+
|
|
954
|
+
```
|
|
955
|
+
app/components/user_table.rsx:14:in `rsx_render': undefined method `name' for nil (NoMethodError)
|
|
956
|
+
```
|
|
957
|
+
|
|
958
|
+
RSX raises a small set of errors, all descending from `RSX::Error`:
|
|
959
|
+
|
|
960
|
+
| Error | Cause |
|
|
961
|
+
| --- | --- |
|
|
962
|
+
| `RSX::SyntaxError` | Malformed markup, with file and line: unterminated tag, missing `}`, unclosed element |
|
|
963
|
+
| `RSX::PropsError` | A missing required prop, or an undeclared prop on a component without `**rest` |
|
|
964
|
+
| `RSX::UnknownComponentError` | A tag that resolves to nothing renderable |
|
|
965
|
+
| `RSX::FileNotFoundError` | An `import` or path that cannot be resolved, listing where RSX looked |
|
|
966
|
+
|
|
967
|
+
When something renders unexpectedly, `rsx compile FILE` (or `RSX.compile(source)`) shows the
|
|
968
|
+
generated Ruby, which is usually enough to see what happened.
|
|
969
|
+
|
|
970
|
+
---
|
|
971
|
+
|
|
972
|
+
## Testing
|
|
973
|
+
|
|
974
|
+
Components are plain Ruby objects, so they can be tested without a request or a view:
|
|
975
|
+
|
|
976
|
+
```ruby
|
|
977
|
+
require "rsx"
|
|
978
|
+
|
|
979
|
+
class BadgeTest < Minitest::Test
|
|
980
|
+
def setup
|
|
981
|
+
RSX.config.paths = ["app/components"]
|
|
982
|
+
RSX.load("app/components/badge.rsx")
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
def test_renders_the_label
|
|
986
|
+
assert_equal %(<span class="badge badge-neutral">Live</span>), Badge.call(label: "Live").to_s
|
|
987
|
+
end
|
|
988
|
+
end
|
|
989
|
+
```
|
|
990
|
+
|
|
991
|
+
`RSX.render_source` renders a string of `.rsx` directly, which keeps markup tests to one line:
|
|
992
|
+
|
|
993
|
+
```ruby
|
|
994
|
+
assert_equal "<p><b></p>", RSX.render_source("<p>{props[:x]}</p>", x: "<b>").to_s
|
|
995
|
+
```
|
|
996
|
+
|
|
997
|
+
RSX's own suite (compiler, runtime, components, caching, Rails integration, and every file in
|
|
998
|
+
`examples/`) runs with:
|
|
999
|
+
|
|
1000
|
+
```bash
|
|
1001
|
+
rake test
|
|
1002
|
+
```
|
|
1003
|
+
|
|
1004
|
+
---
|
|
1005
|
+
|
|
1006
|
+
## Examples
|
|
1007
|
+
|
|
1008
|
+
| File | Shows |
|
|
1009
|
+
| --- | --- |
|
|
1010
|
+
| [`examples/user_profile.rsx`](examples/user_profile.rsx) | The React example above, ported: variables, methods, inline styles, ternaries |
|
|
1011
|
+
| [`examples/components/button.rsx`](examples/components/button.rsx) | Prop defaults, required props, pass-through `**rest` |
|
|
1012
|
+
| [`examples/components/card.rsx`](examples/components/card.rsx) | Children and markup-valued slot props |
|
|
1013
|
+
| [`examples/components/user_table.rsx`](examples/components/user_table.rsx) | Loops, computed classes, inline styles, helper methods, empty states |
|
|
1014
|
+
| [`examples/components/sidebar.rsx`](examples/components/sidebar.rsx) | Component caching and fragment caching |
|
|
1015
|
+
| [`examples/components/theme.rsx`](examples/components/theme.rsx) | Context providers and consumers |
|
|
1016
|
+
| [`examples/views/dashboard.html.rsx`](examples/views/dashboard.html.rsx) | A Rails view composing all of the above |
|
|
1017
|
+
|
|
1018
|
+
---
|
|
1019
|
+
|
|
1020
|
+
## License
|
|
1021
|
+
|
|
1022
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|