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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +34 -0
- data/LICENSE +21 -0
- data/README.md +1022 -0
- data/examples/components/button.rsx +22 -0
- data/examples/components/card.rsx +21 -0
- data/examples/components/sidebar.rsx +29 -0
- data/examples/components/theme.rsx +29 -0
- data/examples/components/user_table.rsx +64 -0
- data/examples/user_profile.rsx +51 -0
- data/examples/views/dashboard.html.rsx +26 -0
- data/exe/rsx +10 -0
- data/lib/rsx/attributes.rb +313 -0
- data/lib/rsx/cache.rb +102 -0
- data/lib/rsx/children.rb +78 -0
- data/lib/rsx/cli.rb +96 -0
- data/lib/rsx/codegen.rb +320 -0
- data/lib/rsx/compile_cache.rb +84 -0
- data/lib/rsx/component.rb +212 -0
- data/lib/rsx/context.rb +67 -0
- data/lib/rsx/errors.rb +27 -0
- data/lib/rsx/escape.rb +59 -0
- data/lib/rsx/helpers.rb +29 -0
- data/lib/rsx/loader.rb +194 -0
- data/lib/rsx/nodes.rb +26 -0
- data/lib/rsx/railtie.rb +81 -0
- data/lib/rsx/safe_string.rb +42 -0
- data/lib/rsx/tasks.rake +26 -0
- data/lib/rsx/template.rb +63 -0
- data/lib/rsx/template_handler.rb +35 -0
- data/lib/rsx/transformer.rb +1034 -0
- data/lib/rsx/version.rb +8 -0
- data/lib/rsx-rb.rb +4 -0
- data/lib/rsx.rb +430 -0
- metadata +86 -0
|
@@ -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,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
|