grsx 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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/main.yml +20 -0
  3. data/.gitignore +5 -0
  4. data/.rspec +2 -0
  5. data/.travis.yml +6 -0
  6. data/Appraisals +17 -0
  7. data/CODE_OF_CONDUCT.md +74 -0
  8. data/Dockerfile +8 -0
  9. data/Gemfile +7 -0
  10. data/Gemfile.lock +274 -0
  11. data/Guardfile +70 -0
  12. data/LICENSE.txt +21 -0
  13. data/README.md +437 -0
  14. data/Rakefile +6 -0
  15. data/bin/console +14 -0
  16. data/bin/setup +8 -0
  17. data/bin/test +43 -0
  18. data/docker-compose.yml +29 -0
  19. data/gemfiles/.bundle/config +2 -0
  20. data/gemfiles/rails_6_1.gemfile +8 -0
  21. data/gemfiles/rails_6_1.gemfile.lock +260 -0
  22. data/gemfiles/rails_7_0.gemfile +7 -0
  23. data/gemfiles/rails_7_0.gemfile.lock +265 -0
  24. data/gemfiles/rails_7_1.gemfile +7 -0
  25. data/gemfiles/rails_7_1.gemfile.lock +295 -0
  26. data/gemfiles/rails_7_2.gemfile +7 -0
  27. data/gemfiles/rails_7_2.gemfile.lock +290 -0
  28. data/gemfiles/rails_8_0.gemfile +8 -0
  29. data/gemfiles/rails_8_0.gemfile.lock +344 -0
  30. data/gemfiles/rails_8_1.gemfile +8 -0
  31. data/gemfiles/rails_8_1.gemfile.lock +313 -0
  32. data/gemfiles/rails_master.gemfile +7 -0
  33. data/gemfiles/rails_master.gemfile.lock +296 -0
  34. data/grsx.gemspec +43 -0
  35. data/lib/generators/grsx/phlex_component/phlex_component_generator.rb +40 -0
  36. data/lib/generators/grsx/phlex_component/templates/component.rb.tt +12 -0
  37. data/lib/generators/grsx/phlex_component/templates/component.rbx.tt +6 -0
  38. data/lib/grsx/component_resolver.rb +64 -0
  39. data/lib/grsx/configuration.rb +14 -0
  40. data/lib/grsx/lexer.rb +325 -0
  41. data/lib/grsx/nodes/abstract_attr.rb +12 -0
  42. data/lib/grsx/nodes/abstract_element.rb +13 -0
  43. data/lib/grsx/nodes/abstract_node.rb +31 -0
  44. data/lib/grsx/nodes/component_element.rb +69 -0
  45. data/lib/grsx/nodes/component_prop.rb +29 -0
  46. data/lib/grsx/nodes/declaration.rb +15 -0
  47. data/lib/grsx/nodes/expression.rb +15 -0
  48. data/lib/grsx/nodes/expression_group.rb +15 -0
  49. data/lib/grsx/nodes/fragment.rb +30 -0
  50. data/lib/grsx/nodes/html_attr.rb +13 -0
  51. data/lib/grsx/nodes/html_element.rb +49 -0
  52. data/lib/grsx/nodes/newline.rb +9 -0
  53. data/lib/grsx/nodes/raw.rb +23 -0
  54. data/lib/grsx/nodes/root.rb +19 -0
  55. data/lib/grsx/nodes/text.rb +15 -0
  56. data/lib/grsx/nodes/util.rb +9 -0
  57. data/lib/grsx/nodes.rb +20 -0
  58. data/lib/grsx/parser.rb +238 -0
  59. data/lib/grsx/phlex_compiler.rb +223 -0
  60. data/lib/grsx/phlex_component.rb +361 -0
  61. data/lib/grsx/phlex_runtime.rb +70 -0
  62. data/lib/grsx/prop_inspector.rb +52 -0
  63. data/lib/grsx/rails/engine.rb +24 -0
  64. data/lib/grsx/rails/phlex_reloader.rb +25 -0
  65. data/lib/grsx/template.rb +12 -0
  66. data/lib/grsx/version.rb +3 -0
  67. data/lib/grsx.rb +35 -0
  68. metadata +324 -0
data/README.md ADDED
@@ -0,0 +1,437 @@
1
+ # A Ruby template language inspired by JSX
2
+
3
+ [![Build Status](https://github.com/patbenatar/grsx/actions/workflows/main.yml/badge.svg?branch=master)](https://github.com/patbenatar/grsx/actions?query=branch%3Amaster)
4
+
5
+ * [Getting Started](#getting-started-with-rails)
6
+ * [Template Syntax](#template-syntax)
7
+ * [Components](#components)
8
+ * [`Grsx::Component`](#grsxcomponent)
9
+ * [Usage with any component library](#usage-with-any-component-library)
10
+ * [Fragment caching in Rails](#fragment-caching-in-rails)
11
+ * [Advanced](#advanced)
12
+ * [Component resolution](#component-resolution)
13
+ * [AST Transforms](#ast-transforms)
14
+ * [Usage outside of Rails](#usage-outside-of-rails)
15
+
16
+ ## Manifesto
17
+
18
+ Love JSX and component-based frontends, but sick of paying the costs of SPA development? Grsx brings the elegance of JSX—operating on HTML elements and custom components with an interchangeable syntax—to the world of Rails server-rendered apps.
19
+
20
+ Combine this with CSS Modules in your Webpacker PostCSS pipeline and you'll have a first-class frontend development experience while maintaining the development efficiency of Rails.
21
+
22
+ _But what about Javascript and client-side behavior?_ You probably don't need as much of it as you think you do. See how far you can get with layering RailsUJS, vanilla JS, Turbolinks, and/or StimulusJS onto your server-rendered components. I think you'll be pleasantly surprised with the modern UX you're able to build while writing and maintaining less code.
23
+
24
+ ## Example
25
+
26
+ Use your custom Ruby class components from `.rbx` templates just like you would React components in JSX:
27
+
28
+ ```jsx
29
+ <body>
30
+ <Hero size="fullscreen" {**splat_some_attributes}>
31
+ <h1>Hello {@name}</h1>
32
+ <p>Welcome to grsx, marrying the nice parts of React templating with the development efficiency of Rails server-rendered apps.</p>
33
+ <Button to={about_path}>Learn more</Button>
34
+ </Hero>
35
+ </body>
36
+ ```
37
+
38
+ after defining them in Ruby:
39
+
40
+ ```ruby
41
+ class HeroComponent < Grsx::Component # or use ViewComponent, or another component lib
42
+ def setup(size:)
43
+ @size = size
44
+ end
45
+ end
46
+
47
+ class ButtonComponent < Grsx::Component
48
+ def setup(to:)
49
+ @to = to
50
+ end
51
+ end
52
+ ```
53
+
54
+ with their accompying template files (also can be `.rbx`!), scoped scss files, JS and other assets (not shown).
55
+
56
+ ## Getting Started (with Rails)
57
+
58
+ Add it to your Gemfile and `bundle install`:
59
+
60
+ ```ruby
61
+ gem "grsx"
62
+ ```
63
+
64
+ _From 1.0 onward, we only support Rails 6. If you're using Rails 5, use the 0.x releases._
65
+
66
+ _Not using Rails? See "Usage outside of Rails" below._
67
+
68
+ Create your first component at `app/components/hello_world_component.rb`:
69
+
70
+ ```ruby
71
+ class HelloWorldComponent < Grsx::Component
72
+ def setup(name:)
73
+ @name = name
74
+ end
75
+ end
76
+ ```
77
+
78
+ With a template `app/components/hello_world_component.rbx`:
79
+
80
+ ```jsx
81
+ <div>
82
+ <h1>Hello {@name}</h1>
83
+ {content}
84
+ </div>
85
+ ```
86
+
87
+ Add a controller, action, route, and `rbx` view like `app/views/hello_worlds/index.rbx`:
88
+
89
+ ```jsx
90
+ <HelloWorld name="Nick">
91
+ <p>Welcome to the world of component-based frontend development in Rails!</p>
92
+ </HelloWorld>
93
+ ```
94
+
95
+ Fire up `rails s`, navigate to your route, and you should see Grsx in action!
96
+
97
+ ## Template Syntax
98
+
99
+ You can use Ruby code within brackets:
100
+
101
+ ```jsx
102
+ <p class={@dynamic_class}>
103
+ Hello {"world".upcase}
104
+ </p>
105
+ ```
106
+
107
+ You can splat a hash into attributes:
108
+
109
+ ```jsx
110
+ <div {**{class: "myClass"}} {**@more_attrs}></div>
111
+ ```
112
+
113
+ You can use HTML or component tags within expressions. e.g. to conditionalize a template:
114
+
115
+ ```jsx
116
+ <div>
117
+ {some_boolean && <h1>Welcome</h1>}
118
+ {another_boolean ? <p>Option One</p> : <p>Option Two</p>}
119
+ </div>
120
+ ```
121
+
122
+ Or in loops:
123
+
124
+ ```jsx
125
+ <ul>
126
+ {[1, 2, 3].map { |n| <li>{n}</li> }}
127
+ </ul>
128
+ ```
129
+
130
+ Blocks:
131
+
132
+ ```jsx
133
+ {link_to "/" do
134
+ <span>Click me</span>
135
+ end}
136
+ ```
137
+
138
+ Pass a tag to a component as an attribute:
139
+
140
+ ```jsx
141
+ <Hero title={<h1>Hello World</h1>}>
142
+ Content here...
143
+ </Hero>
144
+ ```
145
+
146
+ Or pass a lambda as an attribute, that when called returns a tag:
147
+
148
+ ```jsx
149
+ <Hero title={-> { <h1>Hello World</h1> }}>
150
+ Content here...
151
+ </Hero>
152
+ ```
153
+
154
+ _Note that when using tags inside blocks, the block must evaluate to a single root element. Grsx behaves similar to JSX in this way. E.g.:_
155
+
156
+ ```
157
+ # Do
158
+ -> { <span><i>Hello</i> World</span> }
159
+
160
+ # Don't
161
+ -> { <i>Hello</i> World }
162
+ ```
163
+
164
+ Start a line with `#` to leave a comment:
165
+
166
+ ```jsx
167
+ # Private note to self that won't be rendered in the final HTML
168
+ ```
169
+
170
+ ## Components
171
+
172
+ You can use Ruby classes as components alongside standard HTML tags:
173
+
174
+ ```jsx
175
+ <div>
176
+ <PageHeader title="Welcome" />
177
+ <PageBody>
178
+ <p>To the world of custom components</p>
179
+ </PageBody>
180
+ </div>
181
+ ```
182
+
183
+ By default, Grsx will resolve `PageHeader` to a Ruby class called `PageHeaderComponent` and render it with the view context, attributes, and its children: `PageHeaderComponent.new(self, title: "Welcome").render_in(self, &block)`. This behavior is customizable, see "Component resolution" below.
184
+
185
+ ### `Grsx::Component`
186
+
187
+ We ship with a component superclass that integrates nicely with Rails' ActionView and the controller rendering context. You can use it to easily implement custom components in your Rails app:
188
+
189
+ ```ruby
190
+ # app/components/page_header_component.rb
191
+ class PageHeaderComponent < Grsx::Component
192
+ def setup(title:)
193
+ @title = title
194
+ end
195
+ end
196
+ ```
197
+
198
+ By default, we'll look for a template file in the same directory as the class and with a matching filename:
199
+
200
+ ```jsx
201
+ // app/components/page_header_component.rbx
202
+ <h1>{@title}</h1>
203
+ ```
204
+
205
+ Your components and their templates run in the same context as traditional Rails views, so you have access to all of the view helpers you're used to as well as any custom helpers you've defined in `app/helpers/` or via `helper_method` in your controller.
206
+
207
+ #### Template-less components
208
+
209
+ If you'd prefer to render your components entirely from Ruby, you can do so by implementing `#call`:
210
+
211
+ ```ruby
212
+ class PageHeaderComponent < Grsx::Component
213
+ def setup(title:)
214
+ @title = title
215
+ end
216
+
217
+ def call
218
+ tag.h1 @title
219
+ end
220
+ end
221
+ ```
222
+
223
+ #### Context
224
+
225
+ `Grsx::Component` implements a similar notion to React's Context API, allowing you to pass data through the component tree without having to pass props down manually.
226
+
227
+ Given a template:
228
+
229
+ ```jsx
230
+ <Form>
231
+ <TextField field={:title} />
232
+ </Form>
233
+ ```
234
+
235
+ The form component can use Rails `form_for` and then pass the `form` builder object down to any field components using context:
236
+
237
+ ```ruby
238
+ class FormComponent < Grsx::Component
239
+ def setup(form_object:)
240
+ @form_object = form_object
241
+ end
242
+
243
+ def call
244
+ form_for @form_object do |form|
245
+ create_context(:form, form)
246
+ content
247
+ end
248
+ end
249
+ end
250
+
251
+ class TextFieldComponent < Grsx::Component
252
+ def setup(field:)
253
+ @field = field
254
+ @form = use_context(:form)
255
+ end
256
+
257
+ def call
258
+ @form.text_field @field
259
+ end
260
+ end
261
+ ```
262
+
263
+ #### Usage with ERB
264
+
265
+ We recommend using `Grsx::Component` with the rbx template language, but if you prefer ERB... a component's template can be `.html.erb` and you can render a component from ERB like so:
266
+
267
+ Rails 6.1:
268
+
269
+ ```erb
270
+ <%= render PageHeaderComponent.new(self, title: "Welcome") do %>
271
+ <p>Children...</p>
272
+ <% end >
273
+ ```
274
+
275
+ Rails 6.0 or earlier:
276
+
277
+ ```erb
278
+ <%= PageHeaderComponent.new(self, title: "Welcome").render_in(self) %>
279
+ ```
280
+
281
+ ### Usage with any component library
282
+
283
+ You can use the rbx template language with other component libraries like Github's view_component. You just need to tell Grsx how to render the component:
284
+
285
+ ```ruby
286
+ # config/initializers/grsx.rb
287
+ Grsx.configure do |config|
288
+ config.component_rendering_templates = {
289
+ children: "{capture{%{children}}}",
290
+ component: "::%{component_class}.new(%{view_context},%{kwargs}).render_in%{children_block}"
291
+ }
292
+ end
293
+ ```
294
+
295
+ ## Fragment caching in Rails
296
+
297
+ `.rbx` templates integrate with Rails fragment caching, automatically cachebusting when the template or its render dependencies change.
298
+
299
+ If you're using `Grsx::Component`, you can further benefit from component cachebusting where the fragment cache will be busted if any dependent component's template _or_ class definition changes.
300
+
301
+ And you can use `<Grsx.Cache>`, a convenient wrapper for the Rails fragment cache:
302
+
303
+ ```rbx
304
+ <Grsx.Cache key={...}>
305
+ <p>Fragment here...</p>
306
+ <MyButton />
307
+ </Grsx.Cache>
308
+ ```
309
+
310
+ ## Advanced
311
+
312
+ ### Component resolution
313
+
314
+ By default, Grsx resolves component tags to Ruby classes named `#{tag}Component`, e.g.:
315
+
316
+ * `<PageHeader />` => `PageHeaderComponent`
317
+ * `<Admin.Button />` => `Admin::ButtonComponent`
318
+
319
+ You can customize this behavior by providing a custom resolver:
320
+
321
+ ```ruby
322
+ # config/initializers/grsx.rb
323
+ Grsx.configure do |config|
324
+ config.element_resolver = MyResolver.new
325
+ end
326
+ ```
327
+
328
+ Where `MyResolver` implements the following API:
329
+
330
+ * `component?(name: string, template: Grsx::Template) => Boolean`
331
+ * `component_class(name: string, template: Grsx::Template) => T`
332
+
333
+ See `lib/grsx/component_resolver.rb` for an example.
334
+
335
+ #### Auto-namespacing
336
+
337
+ Want to namespace your components but sick of typing `Admin.` in front of every component call? Grsx's default `ComponentResolver` implementation has an option for that:
338
+
339
+ ```ruby
340
+ # config/initializers/grsx.rb
341
+ Grsx.configure do |config|
342
+ config.element_resolver.component_namespaces = {
343
+ Rails.root.join("app", "views", "admin") => %w[Admin],
344
+ Rails.root.join("app", "components", "admin") => %w[Admin]
345
+ }
346
+ end
347
+ ```
348
+
349
+ Now any calls to `<Button>` made from `.rbx` views within `app/views/admin/` or from component templates within `app/components/admin/` will first check for `Admin::ButtonComponent` before `ButtonComponent`.
350
+
351
+ ### AST Transforms
352
+
353
+ You can hook into Grsx's compilation process to mutate the abstract syntax tree. This is both useful and dangerous, so use with caution.
354
+
355
+ An example use case is automatically scoping CSS class names if you're using something like CSS Modules. Here's an oversimplified example of this:
356
+
357
+ ```ruby
358
+ # config/initializers/grsx.rb
359
+ Grsx.configure do |config|
360
+ config.transforms.register(Grsx::Nodes::HTMLAttr) do |node, context|
361
+ if node.name == "class"
362
+ class_list = node.value.split(" ")
363
+ node.value.content = scope_names(class_list, scope: context.template.identifier)
364
+ end
365
+ end
366
+ end
367
+ ```
368
+
369
+ ### Usage outside of Rails
370
+
371
+ Grsx compiles your template into ruby code, which you can then execute in any context you like. Subclass `Grsx::Runtime` to add methods and instance variables that you'd like to make available to your template.
372
+
373
+ ```ruby
374
+ class MyRuntime < Grsx::Runtime
375
+ def initialize
376
+ super
377
+ @an_ivar = "Ivar value"
378
+ end
379
+
380
+ def a_method
381
+ "Method value"
382
+ end
383
+ end
384
+
385
+ Grsx.evaluate("<p class={a_method}>{@an_ivar}</p>", MyRuntime.new)
386
+ ```
387
+
388
+ ## Development
389
+
390
+ ```
391
+ docker-compose build
392
+ docker-compose run grsx bin/test
393
+ ```
394
+
395
+ Or auto-run tests with guard if you prefer:
396
+
397
+ ```
398
+ docker-compose run grsx guard
399
+ ```
400
+
401
+ If you want to run against the supported versions of Rails, use
402
+ Appraisal:
403
+
404
+ ```
405
+ docker-compose run grsx bundle exec appraisal bin/test
406
+ ```
407
+
408
+ When updating dependency versions in gemspec, you also need to regenerate the appraisal gemspecs with:
409
+
410
+ ```
411
+ docker-compose run grsx bundle exec appraisal install
412
+ ```
413
+
414
+ ## Debugging TemplatePath methods being called
415
+ When a new version of Rails is released, we need to check what methods are being
416
+ called on `Grsx::Component::TemplatePath` to make sure we always return
417
+ a TemplatePath, not a string due to how we handle `TemplatePath`s
418
+ internally.
419
+
420
+ To list all methods being called, enable `GRSX_TEMPLATE_PATH_DEBUG` and
421
+ run tests:
422
+
423
+ ```
424
+ docker-compose run -e GRSX_TEMPLATE_PATH_DEBUG=1 grsx bundle exec appraisal bin/test
425
+ ```
426
+
427
+ ## Contributing
428
+
429
+ Bug reports and pull requests are welcome on GitHub at https://github.com/patbenatar/grsx. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/patbenatar/grsx/blob/master/CODE_OF_CONDUCT.md).
430
+
431
+ ## License
432
+
433
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
434
+
435
+ ## Code of Conduct
436
+
437
+ Everyone interacting in the Grsx project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/patbenatar/grsx/blob/master/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "rbexy"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/bin/test ADDED
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env bash
2
+
3
+ declare -i RESULT=0
4
+
5
+ echo "Running main suite..."
6
+
7
+ bundle exec rspec
8
+ RESULT+=$?
9
+
10
+ echo "Running initial caching specs to test cold cache behavior..."
11
+
12
+ bundle exec rspec spec/integration/caching/before_changes_spec.rb
13
+ RESULT+=$?
14
+
15
+ echo "Making template source changes to allow testing of cache-busting..."
16
+
17
+ templates=(
18
+ "spec/dummy/app/views/caching/inline.rbx"
19
+ "spec/dummy/app/components/cached_thing_component.rbx"
20
+ "spec/dummy/app/components/cached_class_thing_component.rb"
21
+ "spec/dummy/app/components/cached_thing_call_component.rb"
22
+ "spec/dummy/app/views/caching/_partial_render_partial.rbx"
23
+ )
24
+ for i in "${templates[@]}"
25
+ do
26
+ mv $i $i.original
27
+ mv $i.changed $i
28
+ done
29
+
30
+ echo "Running subsequent caching specs to test cache-busting of warm cache..."
31
+
32
+ bundle exec rspec spec/integration/caching/after_changes_spec.rb
33
+ RESULT+=$?
34
+
35
+ echo "Cleaning up..."
36
+
37
+ for i in "${templates[@]}"
38
+ do
39
+ mv $i $i.changed
40
+ mv $i.original $i
41
+ done
42
+
43
+ exit $RESULT
@@ -0,0 +1,29 @@
1
+ version: '3'
2
+
3
+ volumes:
4
+ bundle:
5
+
6
+ services:
7
+ rbexy:
8
+ build: .
9
+ image: rbexy
10
+ volumes:
11
+ - .:/app
12
+ - bundle:/usr/local/bundle
13
+ - $HOME/.ssh:/root/.ssh:ro
14
+ - $HOME/.gitconfig:/root/.gitconfig:ro
15
+ - $HOME/.gem/credentials:/root/.gem/credentials
16
+ working_dir: /app
17
+ dummy:
18
+ image: rbexy
19
+ volumes:
20
+ - .:/app
21
+ - bundle:/usr/local/bundle
22
+ working_dir: /app/spec/dummy/
23
+ command: ./start.sh
24
+ ports:
25
+ - 3000:3000
26
+ environment:
27
+ - RAILS_LOG_STDOUT=1
28
+ tty: true
29
+ stdin_open: true
@@ -0,0 +1,2 @@
1
+ ---
2
+ BUNDLE_RETRY: "1"
@@ -0,0 +1,8 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", "~> 6.1.4"
6
+ gem "net-smtp", require: false
7
+
8
+ gemspec path: "../"