├── .gitignore ├── .reek.yml ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib └── rack │ ├── component.rb │ └── component │ ├── memory_cache.rb │ ├── renderer.rb │ └── version.rb ├── rack-component.gemspec └── spec ├── benchmarks.rb ├── components.rb ├── rack └── component_spec.rb ├── raw_rack_example_spec.rb ├── readme_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | *.gem 10 | Gemfile.lock 11 | 12 | # rspec failure tracking 13 | .rspec_status 14 | -------------------------------------------------------------------------------- /.reek.yml: -------------------------------------------------------------------------------- 1 | detectors: 2 | IrresponsibleModule: 3 | enabled: false 4 | UncommunicativeVariableName: 5 | enabled: false 6 | NestedIterators: 7 | ignore_iterators: 8 | - each_object 9 | UtilityFunction: 10 | enabled: false 11 | UncommunicativeMethodName: 12 | enabled: false 13 | exclude_paths: 14 | - bin 15 | - client 16 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.5 3 | Layout/LeadingCommentSpace: 4 | Enabled: false 5 | Metrics/BlockLength: 6 | Enabled: false 7 | Metrics/MethodLength: 8 | ExcludedMethods: 9 | - render 10 | Style/FrozenStringLiteralComment: 11 | EnforcedStyle: never 12 | Style/AsciiComments: 13 | Enabled: false 14 | StyleGuide: http://relaxed.ruby.style/#styleasciicomments 15 | Style/TrailingCommaInArguments: 16 | Enabled: false 17 | StyleGuide: http://relaxed.ruby.style/#styletrailingcommainarguments 18 | EnforcedStyleForMultiline: consistent_comma 19 | Style/TrailingCommaInArrayLiteral: 20 | Enabled: false 21 | StyleGuide: http://relaxed.ruby.style/#styletrailingcommainliteral 22 | EnforcedStyleForMultiline: consistent_comma 23 | Style/TrailingCommaInHashLiteral: 24 | Enabled: false 25 | StyleGuide: http://relaxed.ruby.style/#styletrailingcommainliteral 26 | EnforcedStyleForMultiline: consistent_comma 27 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: ruby 3 | cache: bundler 4 | rvm: 5 | - 2.6.2 6 | - 2.5.5 7 | - 2.4 8 | - 2.3 9 | before_install: 10 | - gem update --system 11 | - gem install bundler 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## 0.5.0 8 | ### Fixed 9 | - The `env` argument of the `render` block is now optional, as per standard Ruby 10 | block behavior. 11 | ```ruby 12 | class WorksInThisVersion < Rack::Component 13 | render do 14 | 'This component raised an ArgumentError in old versions but works now.' 15 | end 16 | end 17 | 18 | class StillWorks < Rack::Component 19 | render do |env| 20 | 'This style still works. Using |keyword:, arguments:| in env is nice.' 21 | end 22 | end 23 | ``` 24 | 25 | ### Added 26 | - A changelog 27 | - Templating via [tilt](https://github.com/rtomayko/tilt), with support for 28 | escaping HTML by default 29 | 30 | ### Removed 31 | - Calling `Component.memoized(env)` is no longer supported. Use Sam Saffron's 32 | [lru_redux](https://github.com/SamSaffron/lru_redux) as an almost drop-in 33 | replacement, like this: 34 | 35 | ```ruby 36 | require 'rack/component' 37 | require 'lru_redux' 38 | class MyComponent < Rack::Component 39 | Cache = LruRedux::ThreadSafeCache.new(100) 40 | 41 | render do |env| 42 | Cache.getset(env) { 'this block will render after checking the cache' } 43 | end 44 | end 45 | ``` 46 | 47 | ## 0.4.2 - 2019-01-04 48 | ### Added 49 | - `#h` method for escaping HTML inside interpolated strings 50 | 51 | ## 0.4.1 - 2019-01-02 52 | - First public, documented release 53 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in rack-component.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rack::Component 2 | 3 | Like a React.js component, a `Rack::Component` implements a `render` method that 4 | takes input data and returns what to display. You can use Components instead of 5 | Controllers, Views, Templates, and Helpers, in any Rack app. 6 | 7 | ## Install 8 | 9 | Add `rack-component` to your Gemfile and run `bundle install`: 10 | 11 | ``` 12 | gem 'rack-component' 13 | ``` 14 | 15 | ## Quickstart with Sinatra 16 | 17 | ```ruby 18 | # config.ru 19 | require 'sinatra' 20 | require 'rack/component' 21 | 22 | class Hello < Rack::Component 23 | render do |env| 24 | "

Hello, #{h env[:name]}

" 25 | end 26 | end 27 | 28 | get '/hello/:name' do 29 | Hello.call(name: params[:name]) 30 | end 31 | 32 | run Sinatra::Application 33 | ``` 34 | 35 | **Note that Rack::Component does not escape strings by default**. To escape 36 | strings, you can either use the `#h` helper like in the example above, or you 37 | can configure your components to render a template that escapes automatically. 38 | See the [Recipes](#recipes) section for details. 39 | 40 | ## Table of Contents 41 | 42 | * [Getting Started](#getting-started) 43 | * [Components as plain functions](#components-as-plain-functions) 44 | * [Components as Rack::Components](#components-as-rackcomponents) 45 | * [Components if you hate inheritance](#components-if-you-hate-inheritance) 46 | * [Recipes](#recipes) 47 | * [Render one component inside another](#render-one-component-inside-another) 48 | * [Render a template that escapes output by default via Tilt](#render-a-template-that-escapes-output-by-default-via-tilt) 49 | * [Render an HTML list from an array](#render-an-html-list-from-an-array) 50 | * [Render a Rack::Component from a Rails controller](#render-a-rackcomponent-from-a-rails-controller) 51 | * [Mount a Rack::Component as a Rack app](#mount-a-rackcomponent-as-a-rack-app) 52 | * [Build an entire App out of Rack::Components](#build-an-entire-app-out-of-rackcomponents) 53 | * [Define `#render` at the instance level instead of via `render do`](#define-render-at-the-instance-level-instead-of-via-render-do) 54 | * [API Reference](#api-reference) 55 | * [Performance](#performance) 56 | * [Compatibility](#compatibility) 57 | * [Anybody using this in production?](#anybody-using-this-in-production) 58 | * [Ruby reference](#ruby-reference) 59 | * [Development](#development) 60 | * [Contributing](#contributing) 61 | * [License](#license) 62 | 63 | ## Getting Started 64 | 65 | ### Components as plain functions 66 | 67 | The simplest component is just a lambda that takes an `env` parameter: 68 | 69 | ```ruby 70 | Greeter = lambda do |env| 71 | "

Hi, #{env[:name]}.

" 72 | end 73 | 74 | Greeter.call(name: 'Mina') #=> '

Hi, Mina.

' 75 | ``` 76 | 77 | ### Components as Rack::Components 78 | 79 | Upgrade your lambda to a `Rack::Component` when it needs HTML escaping, instance 80 | methods, or state: 81 | 82 | ```ruby 83 | require 'rack/component' 84 | class FormalGreeter < Rack::Component 85 | render do |env| 86 | "

Hi, #{h title} #{h env[:name]}.

" 87 | end 88 | 89 | # +env+ is available in instance methods too 90 | def title 91 | env[:title] || "Queen" 92 | end 93 | end 94 | 95 | FormalGreeter.call(name: 'Franklin') #=> "

Hi, Queen Franklin.

" 96 | FormalGreeter.call( 97 | title: 'Captain', 98 | name: 'Kirk ' 99 | ) #=>

Hi, Captain Kirk <kirk@starfleet.gov>.

100 | ``` 101 | 102 | #### Components if you hate inheritance 103 | 104 | Instead of inheriting from `Rack::Component`, you can `extend` its methods: 105 | 106 | ```ruby 107 | class SoloComponent 108 | extend Rack::Component::Methods 109 | render { "Family is complicated" } 110 | end 111 | ``` 112 | 113 | ## Recipes 114 | 115 | ### Render one component inside another 116 | 117 | You can nest Rack::Components as if they were [React Children][jsx children] by 118 | calling them with a block. 119 | 120 | ```ruby 121 | Layout.call(title: 'Home') do 122 | Content.call 123 | end 124 | ``` 125 | 126 | Here's a more fully fleshed example: 127 | 128 | ```ruby 129 | require 'rack/component' 130 | 131 | # let's say this is a Sinatra app: 132 | get '/posts/:id' do 133 | PostPage.call(id: params[:id]) 134 | end 135 | 136 | # Fetch a post from the database and render it inside a Layout 137 | class PostPage < Rack::Component 138 | render do |env| 139 | post = Post.find env[:id] 140 | # Nest a PostContent instance inside a Layout instance, 141 | # with some arbitrary HTML too 142 | Layout.call(title: post.title) do 143 | <<~HTML 144 |
145 | #{PostContent.call(title: post.title, body: post.body)} 146 |
147 | I am a footer. 148 |
149 |
150 | HTML 151 | end 152 | end 153 | end 154 | 155 | class Layout < Rack::Component 156 | # The +render+ macro supports Ruby's keyword arguments, and, like any other 157 | # Ruby function, can accept a block via the & operator. 158 | # Here, :title is a required key in +env+, and &child is just a regular Ruby 159 | # block that could be named anything. 160 | render do |title:, **, &child| 161 | <<~HTML 162 | 163 | 164 | 165 | #{h title} 166 | 167 | 168 | #{child.call} 169 | 170 | 171 | HTML 172 | end 173 | end 174 | 175 | class PostContent < Rack::Component 176 | render do |title:, body:, **| 177 | <<~HTML 178 |
179 |

#{h title}

180 | #{h body} 181 |
182 | HTML 183 | end 184 | end 185 | ``` 186 | 187 | ### Render a template that escapes output by default via Tilt 188 | 189 | If you add [Tilt][tilt] and `erubi` to your Gemfile, you can use the `render` 190 | macro with an automatically-escaped template instead of a block. 191 | 192 | ```ruby 193 | # Gemfile 194 | gem 'tilt' 195 | gem 'erubi' 196 | gem 'rack-component' 197 | 198 | # my_component.rb 199 | class TemplateComponent < Rack::Component 200 | render erb: <<~ERB 201 |

Hello, <%= name %>

202 | ERB 203 | 204 | def name 205 | env[:name] || 'Someone' 206 | end 207 | end 208 | 209 | TemplateComponent.call #=>

Hello, Someone

210 | TemplateComponent.call(name: 'Spock<>') #=>

Hello, Spock<>

211 | ``` 212 | 213 | Rack::Component passes `{ escape_html: true }` to Tilt by default, which enables 214 | automatic escaping in ERB (via erubi) Haml, and Markdown. To disable automatic 215 | escaping, or to pass other tilt options, use an `opts: {}` key in `render`: 216 | 217 | ```ruby 218 | class OptionsComponent < Rack::Component 219 | render opts: { escape_html: false, trim: false }, erb: <<~ERB 220 |
221 | Hi there, <%= {env[:name] %> 222 | <%== yield %> 223 |
224 | ERB 225 | end 226 | ``` 227 | 228 | Template components support using the `yield` keyword to render child 229 | components, but note the double-equals `<%==` in the example above. If your 230 | component escapes HTML, and you're yielding to a component that renders HTML, 231 | you probably want to disable escaping via `==`, just for the `<%== yield %>` 232 | call. This is safe, as long as the component you're yielding to uses escaping. 233 | 234 | Using `erb` as a key for the inline template is a shorthand, which also works 235 | with `haml` and `markdown`. But you can also specify `engine` and `template` 236 | explicitly. 237 | 238 | ```ruby 239 | require 'haml' 240 | class HamlComponent < Rack::Component 241 | # Note the special HEREDOC syntax for inline Haml templates! Without the 242 | # single-quotes, Ruby will interpret #{strings} before Haml does. 243 | render engine: 'haml', template: <<~'HAML' 244 | %h1 Hi #{env[:name]}. 245 | HAML 246 | end 247 | ``` 248 | 249 | Using a template instead of raw string interpolation is a safer default, but it 250 | can make it less convenient to do logic while rendering. Feel free to override 251 | your Component's `#initialize` method and do logic there: 252 | 253 | ```ruby 254 | class EscapedPostView < Rack::Component 255 | def initialize(env) 256 | @post = Post.find(env[:id]) 257 | # calling `super` will populate the instance-level `env` hash, making 258 | # `env` available outside this method. But it's fine to skip it. 259 | super 260 | end 261 | 262 | render erb: <<~ERB 263 |
264 |

<%= @post.title %>

265 | <%= @post.body %> 266 |
267 | ERB 268 | end 269 | ``` 270 | 271 | ### Render an HTML list from an array 272 | 273 | [JSX Lists][jsx lists] use JavaScript's `map` function. Rack::Component does 274 | likewise, only you need to call `join` on the array: 275 | 276 | ```ruby 277 | require 'rack/component' 278 | class PostsList < Rack::Component 279 | render do 280 | <<~HTML 281 |

This is a list of posts

282 | 285 | HTML 286 | end 287 | 288 | def render_items 289 | env[:posts].map { |post| 290 | <<~HTML 291 |
  • 292 | 293 | #{post[:name]} 294 | 295 |
  • 296 | HTML 297 | }.join # unlike JSX, you need to call `join` on your array 298 | end 299 | end 300 | 301 | posts = [{ name: 'First Post', id: 1 }, { name: 'Second', id: 2 }] 302 | PostsList.call(posts: posts) #=>

    This is a list of posts