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.
@@ -0,0 +1,22 @@
1
+ # A small component with typed props, defaults and pass-through attributes.
2
+ #
3
+ # Required props are declared as required keywords, optional ones get defaults,
4
+ # and `**rest` collects anything else so callers can add ids, data attributes or
5
+ # aria attributes without the component knowing about them.
6
+
7
+ component Button do |label: nil, variant: "primary", size: "md", disabled: false, children: nil, **rest|
8
+ classes = ["btn", "btn-#{variant}", "btn-#{size}"]
9
+
10
+ return (
11
+ <button
12
+ className={classes}
13
+ disabled={disabled}
14
+ aria={disabled ? { disabled: true } : nil}
15
+ {**rest}
16
+ >
17
+ {label || children}
18
+ </button>
19
+ )
20
+ end
21
+
22
+ export default Button
@@ -0,0 +1,21 @@
1
+ # Composition: children plus "slot" props that are themselves markup.
2
+ #
3
+ # Because attribute values can be any Ruby expression, a slot is just a prop
4
+ # that happens to hold markup: <Card header={<h1>Title</h1>}>.
5
+
6
+ component Card do |title: nil, header: nil, footer: nil, children: nil|
7
+ return (
8
+ <section className="card">
9
+ {/* A header prop wins over the plain title, like a slot overriding a default */}
10
+ <header className="card-header">
11
+ {header || (title && <h2 className="card-title">{title}</h2>)}
12
+ </header>
13
+
14
+ <div className="card-body">{children}</div>
15
+
16
+ {footer && <footer className="card-footer">{footer}</footer>}
17
+ </section>
18
+ )
19
+ end
20
+
21
+ export default Card
@@ -0,0 +1,29 @@
1
+ # Caching. Two levels are available:
2
+ #
3
+ # 1. `cache:` on the component caches its entire output, keyed by its props.
4
+ # 2. `cache(key) { ... }` inside a body caches one fragment, which is useful
5
+ # when only part of the markup is expensive.
6
+ #
7
+ # Both keys include a digest of this file, so editing the component invalidates
8
+ # what it cached.
9
+
10
+ component Sidebar, cache: { expires_in: 300 } do |section:, unread: 0|
11
+ return (
12
+ <nav className="sidebar" aria={{ label: "Primary" }}>
13
+ <ul>
14
+ {%w[dashboard projects reports settings].map do |name|
15
+ <li key={name} className={{ current: name == section }}>
16
+ <a href={"/#{name}"}>{name.capitalize}</a>
17
+ </li>
18
+ end}
19
+ </ul>
20
+
21
+ {/* Expensive, but only worth caching for a minute */}
22
+ {cache(["sidebar-unread", unread], expires_in: 60) do
23
+ <p className="unread">{unread} unread</p>
24
+ end}
25
+ </nav>
26
+ )
27
+ end
28
+
29
+ export default Sidebar
@@ -0,0 +1,29 @@
1
+ # Context: pass a value down the tree without threading it through props.
2
+
3
+ Theme = RSX.create_context("light", name: "Theme")
4
+
5
+ component ThemedPanel do |children: nil|
6
+ theme = use_context(Theme)
7
+
8
+ return (
9
+ <div className={["panel", "panel-#{theme}"]} data={{ theme: theme }}>
10
+ {children}
11
+ </div>
12
+ )
13
+ end
14
+
15
+ # Anything rendered inside <Theme.Provider value={"dark"}> sees "dark",
16
+ # including components several levels down.
17
+ component ThemeDemo do |props|
18
+ return (
19
+ <>
20
+ <ThemedPanel>Uses the default theme.</ThemedPanel>
21
+
22
+ <Theme.Provider value={"dark"}>
23
+ <ThemedPanel>Dark, because of the provider above.</ThemedPanel>
24
+ </Theme.Provider>
25
+ </>
26
+ )
27
+ end
28
+
29
+ export default ThemeDemo
@@ -0,0 +1,64 @@
1
+ # A more involved component: loops, conditional branches, helper methods,
2
+ # computed classes and inline styles.
3
+
4
+ import Button from "components/button"
5
+
6
+ component UserTable do |users:, sort: :name, current_user: nil|
7
+ # Plain Ruby methods, defined and called like JavaScript function declarations.
8
+ def initials(user)
9
+ user[:name].split.map { |part| part[0] }.join.upcase
10
+ end
11
+
12
+ def status_style(user)
13
+ { color: user[:active] ? "#0a7" : "#999", fontWeight: user[:active] ? 600 : 400 }
14
+ end
15
+
16
+ sorted = users.sort_by { |user| user[sort].to_s }
17
+
18
+ return (
19
+ <table className="users">
20
+ <thead>
21
+ <tr>
22
+ <th>Person</th>
23
+ <th>Status</th>
24
+ <th className="numeric">Posts</th>
25
+ <th></th>
26
+ </tr>
27
+ </thead>
28
+
29
+ <tbody>
30
+ {sorted.map do |user|
31
+ <tr
32
+ key={user[:id]}
33
+ className={{ "is-you" => user == current_user, "is-inactive" => !user[:active] }}
34
+ data={{ user_id: user[:id] }}
35
+ >
36
+ <td>
37
+ <span className="avatar">{initials(user)}</span>
38
+ {user[:name]}
39
+ {user == current_user && <em className="you"> (you)</em>}
40
+ </td>
41
+
42
+ <td style={status_style(user)}>
43
+ {user[:active] ? "Active" : "Inactive"}
44
+ </td>
45
+
46
+ <td className="numeric">{user[:posts_count]}</td>
47
+
48
+ <td>
49
+ <Button label="Edit" variant="link" size="sm" data-user={user[:id]} />
50
+ </td>
51
+ </tr>
52
+ end}
53
+ </tbody>
54
+
55
+ {users.empty? && (
56
+ <tfoot>
57
+ <tr><td colSpan={4}>Nobody here yet.</td></tr>
58
+ </tfoot>
59
+ )}
60
+ </table>
61
+ )
62
+ end
63
+
64
+ export default UserTable
@@ -0,0 +1,51 @@
1
+ component UserProfile do
2
+ # 1. Regular Ruby variables
3
+ user = {
4
+ first_name: "Jane",
5
+ last_name: "Doe",
6
+ avatar_url: "https://placeholder.com/avatar.png",
7
+ is_admin: true
8
+ }
9
+
10
+ # 2. A Ruby method, called from inside the markup
11
+ def format_name(person)
12
+ "#{person[:first_name]} #{person[:last_name]}"
13
+ end
14
+
15
+ # 3. Inline style hash (camelCase properties, just like React)
16
+ alert_style = {
17
+ color: "darkred",
18
+ backgroundColor: "pink",
19
+ padding: "10px",
20
+ borderRadius: "5px"
21
+ }
22
+
23
+ return (
24
+ # Rule: wrap multiple elements in a single root container (or an empty fragment <>)
25
+ <>
26
+ {/* Dynamic text injection using curly braces */}
27
+ <h1>Welcome back, {format_name(user)}!</h1>
28
+
29
+ {/* Dynamic attribute binding using curly braces (no quotes around braces) */}
30
+ <img
31
+ src={user[:avatar_url]}
32
+ alt="User profile picture"
33
+ className="profile-image" # Rule: use 'className' instead of 'class'
34
+ />
35
+
36
+ {/* Conditional rendering using a Ruby ternary */}
37
+ {user[:is_admin] ? (
38
+ <p style={alert_style}>Admin privileges active.</p>
39
+ ) : (
40
+ <p>Standard user account.</p>
41
+ )}
42
+
43
+ {/* Event handlers are JavaScript, so they are written as strings */}
44
+ <button onClick={"alert('Settings opened!')"}>
45
+ Account Settings
46
+ </button>
47
+ </>
48
+ )
49
+ end
50
+
51
+ export default UserProfile
@@ -0,0 +1,26 @@
1
+ # A Rails view: app/views/pages/dashboard.html.rsx
2
+ #
3
+ # Inside a view, `self` is the Rails view context, so controller instance
4
+ # variables and every Rails helper are available directly.
5
+
6
+ import Card from "components/card"
7
+ import Sidebar from "components/sidebar"
8
+ import UserTable from "components/user_table"
9
+
10
+ <div className="dashboard">
11
+ <Sidebar section="dashboard" unread={@unread_count} />
12
+
13
+ <main>
14
+ <h1>Team</h1>
15
+
16
+ <Card title="Everyone" footer={<a href="/users/new">Invite someone</a>}>
17
+ <UserTable users={@users} current_user={@current_user} sort={:name} />
18
+ </Card>
19
+
20
+ {@users.empty? && (
21
+ <Card title="Getting started">
22
+ <p>Invite a teammate to see them listed here.</p>
23
+ </Card>
24
+ )}
25
+ </main>
26
+ </div>
data/exe/rsx ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ lib = File.expand_path("../lib", __dir__)
5
+ $LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib)
6
+
7
+ require "rsx"
8
+ require "rsx/cli"
9
+
10
+ exit RSX::CLI.start(ARGV)
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSX
4
+ # Translates React-style DOM props into HTML attributes.
5
+ #
6
+ # Prop names follow React's conventions (className, htmlFor, tabIndex,
7
+ # strokeWidth, onClick, ...) and are mapped to their HTML spellings. Names that
8
+ # are already lowercase, snake_case or kebab-case pass through unchanged, so
9
+ # idiomatic Ruby markup works too.
10
+ module Attributes
11
+ # React prop => HTML attribute, for names that are not a simple case change.
12
+ PROP_NAMES = {
13
+ "className" => "class",
14
+ "class_name" => "class",
15
+ "htmlFor" => "for",
16
+ "html_for" => "for",
17
+ "httpEquiv" => "http-equiv",
18
+ "acceptCharset" => "accept-charset",
19
+ "charSet" => "charset",
20
+ "tabIndex" => "tabindex",
21
+ "readOnly" => "readonly",
22
+ "maxLength" => "maxlength",
23
+ "minLength" => "minlength",
24
+ "autoComplete" => "autocomplete",
25
+ "autoCapitalize" => "autocapitalize",
26
+ "autoCorrect" => "autocorrect",
27
+ "autoFocus" => "autofocus",
28
+ "autoPlay" => "autoplay",
29
+ "autoSave" => "autosave",
30
+ "crossOrigin" => "crossorigin",
31
+ "dateTime" => "datetime",
32
+ "encType" => "enctype",
33
+ "formAction" => "formaction",
34
+ "formEncType" => "formenctype",
35
+ "formMethod" => "formmethod",
36
+ "formNoValidate" => "formnovalidate",
37
+ "formTarget" => "formtarget",
38
+ "noValidate" => "novalidate",
39
+ "noModule" => "nomodule",
40
+ "srcSet" => "srcset",
41
+ "srcDoc" => "srcdoc",
42
+ "srcLang" => "srclang",
43
+ "hrefLang" => "hreflang",
44
+ "contentEditable" => "contenteditable",
45
+ "spellCheck" => "spellcheck",
46
+ "colSpan" => "colspan",
47
+ "rowSpan" => "rowspan",
48
+ "cellPadding" => "cellpadding",
49
+ "cellSpacing" => "cellspacing",
50
+ "useMap" => "usemap",
51
+ "isMap" => "ismap",
52
+ "allowFullScreen" => "allowfullscreen",
53
+ "allowTransparency" => "allowtransparency",
54
+ "playsInline" => "playsinline",
55
+ "referrerPolicy" => "referrerpolicy",
56
+ "fetchPriority" => "fetchpriority",
57
+ "frameBorder" => "frameborder",
58
+ "marginWidth" => "marginwidth",
59
+ "marginHeight" => "marginheight",
60
+ "mediaGroup" => "mediagroup",
61
+ "inputMode" => "inputmode",
62
+ "enterKeyHint" => "enterkeyhint",
63
+ "imageSizes" => "imagesizes",
64
+ "imageSrcSet" => "imagesrcset",
65
+ "popoverTarget" => "popovertarget",
66
+ "popoverTargetAction" => "popovertargetaction",
67
+ "accessKey" => "accesskey",
68
+ "itemProp" => "itemprop",
69
+ "itemScope" => "itemscope",
70
+ "itemType" => "itemtype",
71
+ "itemID" => "itemid",
72
+ "itemRef" => "itemref",
73
+ "radioGroup" => "radiogroup",
74
+ "defaultValue" => "value",
75
+ "defaultChecked" => "checked",
76
+ "defaultSelected" => "selected"
77
+ }.freeze
78
+
79
+ # SVG/MathML attributes whose camelCase spelling is significant.
80
+ CASE_SENSITIVE = %w[
81
+ attributeName attributeType baseFrequency baseProfile calcMode clipPathUnits
82
+ contentScriptType contentStyleType diffuseConstant edgeMode filterRes filterUnits
83
+ glyphRef gradientTransform gradientUnits kernelMatrix kernelUnitLength keyPoints
84
+ keySplines keyTimes lengthAdjust limitingConeAngle markerHeight markerUnits
85
+ markerWidth maskContentUnits maskUnits numOctaves pathLength patternContentUnits
86
+ patternTransform patternUnits pointsAtX pointsAtY pointsAtZ preserveAlpha
87
+ preserveAspectRatio primitiveUnits refX refY repeatCount repeatDur
88
+ requiredExtensions requiredFeatures specularConstant specularExponent spreadMethod
89
+ startOffset stdDeviation stitchTiles surfaceScale systemLanguage tableValues
90
+ targetX targetY textLength viewBox viewTarget xChannelSelector yChannelSelector
91
+ zoomAndPan
92
+ ].to_h { |name| [name, name] }.freeze
93
+
94
+ # Attributes rendered bare when truthy and omitted when falsy.
95
+ BOOLEAN = %w[
96
+ allowfullscreen async autofocus autoplay checked controls default defer disabled
97
+ formnovalidate hidden inert ismap itemscope loop multiple muted nomodule novalidate
98
+ open playsinline readonly required reversed selected
99
+ ].to_h { |name| [name, true] }.freeze
100
+
101
+ # Elements that must not be given a closing tag.
102
+ VOID = %w[
103
+ area base br col embed hr img input link meta param source track wbr
104
+ ].to_h { |name| [name, true] }.freeze
105
+
106
+ # Elements that may legally use XML self-closing syntax in HTML documents.
107
+ SELF_CLOSING = %w[
108
+ circle ellipse line path polygon polyline rect stop use image animate
109
+ animateMotion animateTransform feBlend feColorMatrix feComposite feFlood
110
+ feGaussianBlur feImage feMergeNode feOffset fePointLight feSpotLight feTile
111
+ feTurbulence mpath set
112
+ ].to_h { |name| [name, true] }.freeze
113
+
114
+ # CSS properties that take a bare number (everything else gets "px").
115
+ UNITLESS_CSS = %w[
116
+ animation-iteration-count aspect-ratio border-image-outset border-image-slice
117
+ border-image-width box-flex box-flex-group box-ordinal-group column-count columns
118
+ flex flex-grow flex-positive flex-shrink flex-negative flex-order font-weight
119
+ grid-area grid-row grid-row-end grid-row-span grid-row-start grid-column
120
+ grid-column-end grid-column-span grid-column-start line-clamp line-height opacity
121
+ order orphans scale tab-size widows z-index zoom fill-opacity flood-opacity
122
+ stop-opacity stroke-dasharray stroke-dashoffset stroke-miterlimit stroke-opacity
123
+ stroke-width
124
+ ].to_h { |name| [name, true] }.freeze
125
+
126
+ # Props that describe the element to RSX rather than to the browser.
127
+ IGNORED = %w[key ref children suppressHydrationWarning].to_h { |name| [name, true] }.freeze
128
+
129
+ INVALID_NAME = %r{[\s"'>/=\0]}
130
+ CAMEL_BOUNDARY = /([a-z0-9])([A-Z])/
131
+
132
+ module_function
133
+
134
+ # Maps a prop name to its HTML attribute name, or nil when the prop should
135
+ # not be rendered at all.
136
+ def attribute_name(prop)
137
+ name = prop.to_s
138
+ return nil if IGNORED.key?(name)
139
+
140
+ mapped = PROP_NAMES[name]
141
+ return mapped if mapped
142
+ return name if CASE_SENSITIVE.key?(name)
143
+ return name.downcase if name.match?(/\Aon[A-Z]/)
144
+ return name unless name.match?(/[A-Z]/)
145
+
146
+ name.gsub(CAMEL_BOUNDARY, '\1-\2').downcase
147
+ end
148
+
149
+ def boolean?(name)
150
+ BOOLEAN.key?(name)
151
+ end
152
+
153
+ def void?(tag)
154
+ VOID.key?(tag)
155
+ end
156
+
157
+ def self_closing?(tag)
158
+ SELF_CLOSING.key?(tag)
159
+ end
160
+
161
+ # Renders one attribute, including its leading space: ` href="/x"`.
162
+ def render(name, value)
163
+ case value
164
+ when nil, false then ""
165
+ when true then boolean?(name) ? " #{name}" : %( #{name}="true")
166
+ else %( #{name}="#{Escape.attribute(value)}")
167
+ end
168
+ end
169
+
170
+ # class={...} accepts a String, Symbol, Array or Hash.
171
+ def render_class(value)
172
+ tokens = class_tokens(value)
173
+ return "" if tokens.empty?
174
+
175
+ %( class="#{Escape.attribute(tokens.join(" "))}")
176
+ end
177
+
178
+ def class_tokens(value)
179
+ case value
180
+ when nil, false, true then []
181
+ when String then value.empty? ? [] : [value]
182
+ when Symbol then [value.to_s]
183
+ when Array then value.flat_map { |item| class_tokens(item) }
184
+ when Hash then value.filter_map { |token, on| token.to_s if on }
185
+ else [value.to_s]
186
+ end
187
+ end
188
+
189
+ # style={...} accepts a String or a Hash of CSS properties.
190
+ def render_style(value)
191
+ css = style_string(value)
192
+ return "" if css.nil? || css.empty?
193
+
194
+ %( style="#{Escape.attribute(css)}")
195
+ end
196
+
197
+ def style_string(value)
198
+ case value
199
+ when nil, false, true then nil
200
+ when String then value
201
+ when Array then value.filter_map { |item| style_string(item) }.join(";")
202
+ when Hash
203
+ value.filter_map do |property, raw|
204
+ next if raw.nil? || raw == false || raw == ""
205
+
206
+ name = css_property(property)
207
+ "#{name}:#{css_value(name, raw)}"
208
+ end.join(";")
209
+ else value.to_s
210
+ end
211
+ end
212
+
213
+ def css_property(property)
214
+ name = property.to_s
215
+ return name unless name.match?(/[A-Z_]/)
216
+
217
+ name = name.tr("_", "-")
218
+ name.gsub(CAMEL_BOUNDARY, '\1-\2').downcase
219
+ end
220
+
221
+ def css_value(name, value)
222
+ return "#{value}px" if value.is_a?(Numeric) && value != 0 && !UNITLESS_CSS.key?(name)
223
+
224
+ value.to_s
225
+ end
226
+
227
+ # data={...} / aria={...} expand a Hash into prefixed attributes.
228
+ def render_nested(prefix, value)
229
+ case value
230
+ when nil, false then ""
231
+ when Hash
232
+ value.filter_map do |key, raw|
233
+ next if raw.nil?
234
+
235
+ # Like React, data-* and aria-* keep booleans as the strings "true"
236
+ # and "false" rather than becoming bare attributes: ARIA values are
237
+ # enumerated, so `aria-hidden` alone means nothing.
238
+ %( #{prefix}-#{css_property(key)}="#{Escape.attribute(nested_value(raw))}")
239
+ end.join
240
+ else render(prefix, value)
241
+ end
242
+ end
243
+
244
+ def nested_value(value)
245
+ case value
246
+ when String, Symbol, Numeric, SafeString then value
247
+ when Array, Hash then RSX.json(value)
248
+ else value.to_s
249
+ end
250
+ end
251
+
252
+ # Combines attribute hashes the way React combines props: names that map to
253
+ # the same HTML attribute collapse, keeping the first position and the last
254
+ # value, so `<a {...attrs} className="link">` overrides the spread.
255
+ def merge(*parts)
256
+ merged = {}
257
+ positions = {}
258
+
259
+ parts.each do |part|
260
+ next if part.nil? || part == false
261
+
262
+ unless part.respond_to?(:each_pair)
263
+ raise ArgumentError, "spread attributes need a Hash, got #{part.class}"
264
+ end
265
+
266
+ part.each_pair do |prop, value|
267
+ name = canonical_name(prop)
268
+ existing = positions[name]
269
+
270
+ if existing
271
+ merged[existing] = value
272
+ else
273
+ positions[name] = prop
274
+ merged[prop] = value
275
+ end
276
+ end
277
+ end
278
+
279
+ merged
280
+ end
281
+
282
+ # The name two props have to share to be considered the same attribute.
283
+ def canonical_name(prop)
284
+ attribute_name(prop) || prop.to_s
285
+ end
286
+
287
+ # Renders a hash of props as attributes: {**props} / {...props}
288
+ def render_all(hash)
289
+ return "" if hash.nil? || hash == false
290
+
291
+ unless hash.respond_to?(:each_pair)
292
+ raise ArgumentError, "spread attributes need a Hash, got #{hash.class}"
293
+ end
294
+
295
+ out = +""
296
+ hash.each_pair do |prop, value|
297
+ case prop.to_s
298
+ when "class", "className", "class_name" then out << render_class(value)
299
+ when "style" then out << render_style(value)
300
+ when "data" then out << render_nested("data", value)
301
+ when "aria" then out << render_nested("aria", value)
302
+ when "dangerouslySetInnerHTML" then next
303
+ else
304
+ name = attribute_name(prop)
305
+ next if name.nil? || name.empty? || name.match?(INVALID_NAME)
306
+
307
+ out << render(name, value)
308
+ end
309
+ end
310
+ out
311
+ end
312
+ end
313
+ end
data/lib/rsx/cache.rb ADDED
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSX
4
+ # Render caches. Any object responding to `fetch(key, expires_in:) { ... }`
5
+ # and `clear` can be used as a store, so Rails.cache, Memcached or Redis need
6
+ # no adapter beyond the thin wrapper below.
7
+ module Cache
8
+ # Thread-safe in-process LRU. This is the default store so caching works
9
+ # with no configuration and no dependencies.
10
+ class Memory
11
+ DEFAULT_MAX_ENTRIES = 4096
12
+
13
+ def initialize(max_entries: DEFAULT_MAX_ENTRIES)
14
+ @max_entries = max_entries
15
+ @entries = {}
16
+ @lock = Mutex.new
17
+ end
18
+
19
+ def fetch(key, expires_in: nil)
20
+ found = read(key)
21
+ return found unless found.nil?
22
+
23
+ write(key, yield, expires_in: expires_in)
24
+ end
25
+
26
+ def read(key)
27
+ @lock.synchronize do
28
+ value, expires_at = @entries[key]
29
+ next nil if value.nil?
30
+
31
+ if expires_at && expires_at < now
32
+ @entries.delete(key)
33
+ next nil
34
+ end
35
+
36
+ # Re-insert so the entry counts as most recently used.
37
+ @entries.delete(key)
38
+ @entries[key] = [value, expires_at]
39
+ value
40
+ end
41
+ end
42
+
43
+ def write(key, value, expires_in: nil)
44
+ @lock.synchronize do
45
+ @entries.delete(key)
46
+ @entries[key] = [value, expires_in && (now + expires_in)]
47
+ @entries.shift while @entries.size > @max_entries
48
+ end
49
+ value
50
+ end
51
+
52
+ def delete(key)
53
+ @lock.synchronize { @entries.delete(key) }
54
+ end
55
+
56
+ def clear
57
+ @lock.synchronize { @entries.clear }
58
+ self
59
+ end
60
+
61
+ def size
62
+ @lock.synchronize { @entries.size }
63
+ end
64
+
65
+ private
66
+
67
+ def now
68
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
69
+ end
70
+ end
71
+
72
+ # Stores fragments in Rails.cache so they live alongside the rest of the
73
+ # application's cache and are invalidated by the same tooling.
74
+ class Rails
75
+ def initialize(store = nil)
76
+ @store = store
77
+ end
78
+
79
+ def store
80
+ @store || ::Rails.cache
81
+ end
82
+
83
+ def fetch(key, expires_in: nil, &block)
84
+ store.fetch(key, expires_in: expires_in, &block)
85
+ end
86
+
87
+ def read(key) = store.read(key)
88
+ def write(key, value, expires_in: nil) = store.write(key, value, expires_in: expires_in)
89
+ def delete(key) = store.delete(key)
90
+ def clear = store.clear
91
+ end
92
+
93
+ # Never caches. Useful in tests and for disabling caching outright.
94
+ class Null
95
+ def fetch(_key, expires_in: nil) = yield
96
+ def read(_key) = nil
97
+ def write(_key, value, expires_in: nil) = value
98
+ def delete(_key) = nil
99
+ def clear = self
100
+ end
101
+ end
102
+ end