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 +4 -4
- data/CHANGELOG.md +47 -2
- data/README.md +96 -4
- data/lib/generators/rsx/component/component_generator.rb +50 -0
- data/lib/generators/rsx/component/templates/component.rsx.tt +15 -0
- data/lib/rsx/attributes.rb +52 -5
- data/lib/rsx/children.rb +2 -2
- data/lib/rsx/cli.rb +7 -1
- data/lib/rsx/codegen.rb +30 -10
- data/lib/rsx/compile_cache.rb +4 -2
- data/lib/rsx/component.rb +3 -10
- data/lib/rsx/loader.rb +49 -13
- data/lib/rsx/nodes.rb +3 -0
- data/lib/rsx/railtie.rb +1 -0
- data/lib/rsx/template.rb +2 -6
- data/lib/rsx/template_handler.rb +32 -0
- data/lib/rsx/transformer.rb +126 -29
- data/lib/rsx/version.rb +2 -2
- data/lib/rsx.rb +28 -15
- metadata +11 -11
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '09ece5a47645dffe35d921bbf2515c6dd929b3946975c26f2c054ebf44eba867'
|
|
4
|
+
data.tar.gz: 557cb7747622220af0b415dc321d47d5bf38701e41673d48d095efc900940ce2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
- **
|
|
944
|
-
|
|
945
|
-
|
|
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 %>
|
data/lib/rsx/attributes.rb
CHANGED
|
@@ -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"'
|
|
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
|
-
|
|
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?
|
|
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(
|
|
49
|
+
def call(...)
|
|
50
50
|
raw = value
|
|
51
|
-
return raw.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")
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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.
|
|
252
|
-
#
|
|
253
|
-
|
|
254
|
-
|
|
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|
|
data/lib/rsx/compile_cache.rb
CHANGED
|
@@ -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
|
-
|
|
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).
|
|
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
|
-
|
|
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,
|
|
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: [],
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
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.
|
|
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)
|
data/lib/rsx/template_handler.rb
CHANGED
|
@@ -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
|
data/lib/rsx/transformer.rb
CHANGED
|
@@ -62,9 +62,16 @@ module RSX
|
|
|
62
62
|
@pending_loop = false
|
|
63
63
|
@jsx_spans = []
|
|
64
64
|
@components = 0
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
#
|
|
241
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
367
|
-
|
|
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
|
-
|
|
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 =
|
|
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 &&
|
|
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(
|
|
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
|
|
658
|
-
|
|
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" &&
|
|
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
|
|
696
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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?
|
|
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
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
|
-
|
|
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.
|
|
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?
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
#
|
|
258
|
-
def
|
|
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
|
-
|
|
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?(
|
|
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.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
-
|
|
8
|
-
autorequire:
|
|
7
|
+
- Jason Brock
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
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/
|
|
61
|
+
homepage: https://github.com/derwydd/rsx
|
|
60
62
|
licenses:
|
|
61
63
|
- MIT
|
|
62
64
|
metadata:
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
changelog_uri: https://github.com/
|
|
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:
|
|
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: []
|