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.
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>&lt;b&gt;</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>&lt;b&gt;</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).