rendry 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 (6) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +122 -0
  4. data/lib/rendry/version.rb +5 -0
  5. data/lib/rendry.rb +412 -0
  6. metadata +51 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 020307f7389d6a1dcdde28eb61c2eaf4367c9d1848584d67b7665fe45a9804a5
4
+ data.tar.gz: 578e5969022b18f937daa40e5fd6419937c8afb906ee0614b1500bc95820c769
5
+ SHA512:
6
+ metadata.gz: e8c0cd070c058b4ea029a9ffd2c831c50ae7547ac3006f65bf282899aec0e089cf09049dcd3996c370a6ca83abc51dd19aeda17a9829755c17c2e7cd6bba9fd8
7
+ data.tar.gz: 9aa71174fe35e25263de312dd46ff6dc3af4a6e45ca3dbde64a6d66f61476b6d37061eccf689a96bf6d3420adb009e0bca930c63f6bc8c5e0ea09cf0ba96a899
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rendry contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # Rendry UI
2
+
3
+ Rendry UI is a lightweight server-rendered UI framework for Ruby. It provides a small DSL for composing HTML with Ruby — explicit markup, safe escaping by default, testable components, and optional hypermedia interactivity with zero mandatory JavaScript.
4
+
5
+ ## Usage
6
+
7
+ ```ruby
8
+ require_relative "lib/rendry"
9
+
10
+ html = Rendry.render do
11
+ main(class: "page") do
12
+ h1 { text "Tasks" }
13
+ p "A simple Ruby interface"
14
+ button "Create task", type: "submit"
15
+ end
16
+ end
17
+
18
+ puts html
19
+ ```
20
+
21
+ Values passed to `text` and element content are escaped automatically. Use `raw` only for an HTML fragment fully controlled by your application.
22
+
23
+ ## Components
24
+
25
+ ```ruby
26
+ class Badge < Rendry::Component
27
+ def initialize(label)
28
+ @label = label
29
+ end
30
+
31
+ def render
32
+ Rendry.render { span(@label, class: "badge") }
33
+ end
34
+ end
35
+
36
+ Rendry.render { component Badge.new("Ready") }
37
+ ```
38
+
39
+ Components keep access to their instance variables inside DSL blocks, and accept a content block via `do...end` or `{ }`.
40
+
41
+ ## Features
42
+
43
+ ### Layout
44
+
45
+ `Rendry::Components::Layout` renders a full HTML document with `title:`, an optional `head:` block, and `view_transitions: true` for native cross-page transitions (pure CSS, no JS).
46
+
47
+ ### Hypermedia (`hx`)
48
+
49
+ Build htmx attributes inline — Hash/Array values become JSON:
50
+
51
+ ```ruby
52
+ button("Delete", **hx(delete: "/tasks/1", target: "closest .task"))
53
+ # => hx-delete="/tasks/1" hx-target="closest .task"
54
+ ```
55
+
56
+ Also available as `Rendry.hx` outside render blocks.
57
+
58
+ ### Selective fragments
59
+
60
+ `fragment("name")` is transparent in normal renders. `Rendry.render(fragments: ["name"])` emits only the matching fragments — even nested inside components — for htmx swap endpoints.
61
+
62
+ ### Fragment cache
63
+
64
+ `cache(*keys) { ... }` memoizes a DSL block in an in-memory FIFO store (`Rendry.cache_store`).
65
+
66
+ ### Refreshing components
67
+
68
+ Declare polling once on the component class:
69
+
70
+ ```ruby
71
+ class Comments < Rendry::Component
72
+ refreshes every: "10s", url: "/comments", target: "#comments"
73
+ end
74
+ ```
75
+
76
+ `component Comments.new(list)` is then wrapped in a `display:contents` div carrying the `hx-*` attributes. Override per call with `component X.new, refresh: { url: "/x" }`.
77
+
78
+ ### Kits
79
+
80
+ Package components into a module:
81
+
82
+ ```ruby
83
+ module Storefront
84
+ extend Rendry::Kit
85
+ component PriceTag # => Storefront[:price_tag]
86
+ end
87
+
88
+ Storefront.render(:price_tag, "19.99")
89
+ ```
90
+
91
+ `Rendry::Components` is itself a kit: `Rendry::Components[:button]`.
92
+
93
+ ## Examples
94
+
95
+ `examples/` contains numbered, standalone mini-projects — see `examples/README.md`:
96
+
97
+ | # | Project | Demo |
98
+ |---|---------|------|
99
+ | 01 | `01_static_page/` | DSL basics |
100
+ | 02 | `02_component_gallery/` | Components + built-in kit |
101
+ | 03 | `03_layout_site/` | Multi-page site, view transitions |
102
+ | 04 | `04_tasks_sinatra/` | Sinatra app, server state |
103
+ | 05 | `05_htmx_tasks/` | htmx fragment swaps |
104
+ | 06 | `06_fragments_cache/` | fragments + cache + `refreshes` |
105
+ | 07 | `07_component_kit/` | `Rendry::Kit` packaging |
106
+
107
+ ```sh
108
+ bundle exec rake # tests
109
+ bundle exec rake examples # smoke-run static examples
110
+ ruby -Ilib test/test_rendry.rb
111
+ ```
112
+
113
+ ## Project direction
114
+
115
+ The core targets four properties:
116
+
117
+ - explicit, readable HTML;
118
+ - safe escaping by default;
119
+ - Ruby components that are easy to test;
120
+ - no mandatory frontend dependency.
121
+
122
+ This is not a complete web framework yet. Routing, forms, state management, and layout rendering currently belong to the host application, such as Sinatra.
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rendry
4
+ VERSION = "0.1.0"
5
+ end
data/lib/rendry.rb ADDED
@@ -0,0 +1,412 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "json"
5
+ require_relative "rendry/version"
6
+
7
+ module Rendry
8
+ class SafeHTML < String
9
+ def html_safe?
10
+ true
11
+ end
12
+ end
13
+
14
+ VOID_ELEMENTS = %w[area base br col embed hr img input link meta param source track wbr].freeze
15
+ BOOLEAN_ATTRIBUTES = %i[allowfullscreen async autofocus autoplay checked controls defer disabled formnovalidate hidden inert loop multiple muted nomodule novalidate open playsinline readonly required reversed selected].freeze
16
+
17
+ class Cache
18
+ def initialize(limit: 128)
19
+ @store = {}
20
+ @limit = limit
21
+ end
22
+
23
+ def fetch(key)
24
+ return @store[key] if @store.key?(key)
25
+
26
+ value = yield
27
+ @store[key] = value
28
+ @store.shift while @store.size > @limit
29
+ value
30
+ end
31
+
32
+ def clear
33
+ @store.clear
34
+ end
35
+ end
36
+
37
+ class Builder
38
+ attr_reader :output
39
+
40
+ def initialize(enabled: true)
41
+ @output = +""
42
+ @enabled = enabled
43
+ end
44
+
45
+ def render
46
+ @output
47
+ end
48
+
49
+ def text(value)
50
+ write CGI.escapeHTML(value.to_s)
51
+ self
52
+ end
53
+
54
+ def raw(value)
55
+ write value.to_s
56
+ self
57
+ end
58
+
59
+ def tag(name, content = nil, **attributes, &block)
60
+ if content.is_a?(Hash) && attributes.empty?
61
+ attributes = content
62
+ content = nil
63
+ end
64
+
65
+ if block
66
+ children = Builder.new(enabled: @enabled)
67
+ Rendry.evaluate(children, &block)
68
+ content = SafeHTML.new(children.render)
69
+ end
70
+
71
+ output = +"<#{name}#{format_attributes(attributes)}>"
72
+ unless VOID_ELEMENTS.include?(name.to_s)
73
+ output << (content.is_a?(SafeHTML) ? content : CGI.escapeHTML(content.to_s))
74
+ output << "</#{name}>"
75
+ end
76
+ write output
77
+ self
78
+ end
79
+
80
+ def fragment(name, &block)
81
+ filter = Rendry.current_fragments
82
+ if filter
83
+ return self unless filter.include?(name.to_s)
84
+
85
+ target = Rendry.root_builder || self
86
+ previous = target.instance_variable_get(:@enabled)
87
+ target.instance_variable_set(:@enabled, true)
88
+ begin
89
+ Rendry.evaluate(target, &block)
90
+ ensure
91
+ target.instance_variable_set(:@enabled, previous)
92
+ end
93
+ else
94
+ Rendry.evaluate(self, &block)
95
+ end
96
+ self
97
+ end
98
+
99
+ def cache(*keys, **_options, &block)
100
+ location = block.source_location.join(":")
101
+ key = "#{location}:#{keys.inspect}"
102
+ html = Rendry.cache_store.fetch(key) do
103
+ child = Builder.new(enabled: @enabled)
104
+ Rendry.evaluate(child, &block)
105
+ SafeHTML.new(child.render)
106
+ end
107
+ write html
108
+ self
109
+ end
110
+
111
+ def hx(**attributes)
112
+ Rendry.hx(**attributes)
113
+ end
114
+
115
+ def component(component_instance, refresh: nil, &block)
116
+ if block && component_instance.respond_to?(:block=) && !component_instance.instance_variable_get(:@block)
117
+ component_instance.block = block
118
+ end
119
+ rendered = component_instance.render
120
+ html = rendered.is_a?(SafeHTML) ? rendered : SafeHTML.new(CGI.escapeHTML(rendered.to_s))
121
+ config = refresh || (component_instance.class.refresh_config if component_instance.class.respond_to?(:refresh_config))
122
+ html = refresh_wrapper(html, config) if config
123
+ if @enabled
124
+ write html
125
+ elsif Rendry.current_fragments
126
+ @output << html
127
+ end
128
+ self
129
+ end
130
+
131
+ def p(content = nil, **attributes, &block)
132
+ tag("p", content, **attributes, &block)
133
+ end
134
+
135
+ def method_missing(name, content = nil, **attributes, &block)
136
+ if name.to_s.match?(/\A[a-z][a-z0-9_]*\z/)
137
+ tag(name.to_s.tr("_", "-"), content, **attributes, &block)
138
+ else
139
+ super
140
+ end
141
+ end
142
+
143
+ def respond_to_missing?(name, include_private = false)
144
+ name.to_s.match?(/\A[a-z][a-z0-9_]*\z/) || super
145
+ end
146
+
147
+ private
148
+
149
+ def write(string)
150
+ @output << string if @enabled
151
+ end
152
+
153
+ def refresh_wrapper(html, config)
154
+ attributes = {
155
+ class: "rendry-refresh",
156
+ style: "display:contents",
157
+ hx_get: config[:url],
158
+ hx_trigger: config[:trigger] || (config[:every] && "every #{config[:every]}") || "load",
159
+ hx_target: config[:target] || "this",
160
+ hx_swap: config[:swap] || "outerHTML"
161
+ }.compact
162
+ output = +"<div#{format_attributes(attributes)}>"
163
+ output << html
164
+ output << "</div>"
165
+ SafeHTML.new(output)
166
+ end
167
+
168
+ def format_attributes(attributes)
169
+ attributes.each_with_object(+"") do |(key, value), result|
170
+ next if value.nil? || value == false
171
+
172
+ attribute = key.to_s.tr("_", "-")
173
+ if BOOLEAN_ATTRIBUTES.include?(key.to_sym)
174
+ result << " #{attribute}"
175
+ else
176
+ result << %( #{attribute}="#{CGI.escapeHTML(value.to_s)}")
177
+ end
178
+ end
179
+ end
180
+ end
181
+
182
+ class Component
183
+ attr_writer :block
184
+
185
+ class << self
186
+ def refreshes(**config)
187
+ @refresh_config = config
188
+ end
189
+
190
+ def refresh_config
191
+ @refresh_config
192
+ end
193
+ end
194
+
195
+ def render
196
+ raise NotImplementedError, "#{self.class} must implement #render"
197
+ end
198
+ end
199
+
200
+ module Kit
201
+ def self.extended(base)
202
+ base.instance_variable_set(:@kit_components, {})
203
+ end
204
+
205
+ def component(klass, as: nil)
206
+ key = (as || klass.name.to_s.split("::").last).to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.to_sym
207
+ kit_components[key] = klass
208
+ klass
209
+ end
210
+
211
+ def [](name)
212
+ kit_components.fetch(name.to_sym) do
213
+ raise KeyError, "unknown component #{name.inspect} in #{self}"
214
+ end
215
+ end
216
+
217
+ def components
218
+ kit_components.dup
219
+ end
220
+
221
+ def render(name, *args, **kwargs, &block)
222
+ self[name].new(*args, **kwargs, &block).render
223
+ end
224
+
225
+ private
226
+
227
+ def kit_components
228
+ @kit_components ||= {}
229
+ end
230
+ end
231
+
232
+ # Built-in Standard UI Components Kit
233
+ module Components
234
+ class Layout < Component
235
+ def initialize(title: "Rendry", head: nil, view_transitions: false, **attributes, &block)
236
+ @title = title
237
+ @head = head
238
+ @view_transitions = view_transitions
239
+ @attributes = attributes
240
+ @block = block
241
+ end
242
+
243
+ def render
244
+ Rendry.render do
245
+ html(lang: "en", **@attributes) do
246
+ head do
247
+ meta(charset: "utf-8")
248
+ meta(name: "viewport", content: "width=device-width, initial-scale=1")
249
+ title(@title)
250
+ if @view_transitions
251
+ style { raw "@view-transition { navigation: auto; }" }
252
+ end
253
+ Rendry.evaluate(self, &@head) if @head
254
+ end
255
+ body do
256
+ Rendry.evaluate(self, &@block) if @block
257
+ end
258
+ end
259
+ end
260
+ end
261
+ end
262
+
263
+ class Button < Component
264
+ def initialize(label = nil, href: nil, variant: "primary", type: "button", **attributes, &block)
265
+ @label = label
266
+ @href = href
267
+ @variant = variant
268
+ @type = type
269
+ @attributes = attributes
270
+ @block = block
271
+ end
272
+
273
+ def render
274
+ css = "rendry-btn rendry-btn-#{@variant} #{@attributes[:class]}".strip
275
+ attrs = @attributes.except(:class)
276
+ if @href
277
+ Rendry.render do
278
+ a(href: @href, class: css, **attrs) do
279
+ @label ? text(@label) : (@block && Rendry.evaluate(self, &@block))
280
+ end
281
+ end
282
+ else
283
+ Rendry.render do
284
+ button(type: @type, class: css, **attrs) do
285
+ @label ? text(@label) : (@block && Rendry.evaluate(self, &@block))
286
+ end
287
+ end
288
+ end
289
+ end
290
+ end
291
+
292
+ class Badge < Component
293
+ def initialize(label, variant: "default", **attributes)
294
+ @label = label
295
+ @variant = variant
296
+ @attributes = attributes
297
+ end
298
+
299
+ def render
300
+ css = "rendry-badge rendry-badge-#{@variant} #{@attributes[:class]}".strip
301
+ Rendry.render do
302
+ span(@label, class: css, **@attributes.except(:class))
303
+ end
304
+ end
305
+ end
306
+
307
+ class Card < Component
308
+ def initialize(title: nil, **attributes, &block)
309
+ @title = title
310
+ @attributes = attributes
311
+ @block = block
312
+ end
313
+
314
+ def render
315
+ css = "rendry-card #{@attributes[:class]}".strip
316
+ Rendry.render do
317
+ div(class: css, **@attributes.except(:class)) do
318
+ h3(@title, class: "rendry-card-title") if @title
319
+ div(class: "rendry-card-body") do
320
+ if @block
321
+ Rendry.evaluate(self, &@block)
322
+ end
323
+ end
324
+ end
325
+ end
326
+ end
327
+ end
328
+
329
+ class Alert < Component
330
+ def initialize(message = nil, variant: "info", **attributes, &block)
331
+ @message = message
332
+ @variant = variant
333
+ @attributes = attributes
334
+ @block = block
335
+ end
336
+
337
+ def render
338
+ css = "rendry-alert rendry-alert-#{@variant} #{@attributes[:class]}".strip
339
+ Rendry.render do
340
+ div(class: css, role: "alert", **@attributes.except(:class)) do
341
+ text(@message) if @message
342
+ if @block
343
+ Rendry.evaluate(self, &@block)
344
+ end
345
+ end
346
+ end
347
+ end
348
+ end
349
+
350
+ extend Kit
351
+ component Layout
352
+ component Button
353
+ component Badge
354
+ component Card
355
+ component Alert
356
+ end
357
+
358
+ module_function
359
+
360
+ def hx(**attributes)
361
+ attributes.each_with_object({}) do |(key, value), result|
362
+ value = JSON.generate(value) if value.is_a?(Hash) || value.is_a?(Array)
363
+ result[:"hx-#{key.to_s.tr('_', '-')}"] = value
364
+ end
365
+ end
366
+
367
+ def cache_store
368
+ @cache_store ||= Cache.new
369
+ end
370
+
371
+ def current_fragments
372
+ @current_fragments
373
+ end
374
+
375
+ def root_builder
376
+ @root_builder
377
+ end
378
+
379
+ def render(fragments: nil, &block)
380
+ filter = fragments || @current_fragments
381
+ builder = Builder.new(enabled: filter.nil?)
382
+ if fragments
383
+ previous_fragments = @current_fragments
384
+ previous_root = @root_builder
385
+ @current_fragments = fragments.map(&:to_s)
386
+ @root_builder = builder
387
+ begin
388
+ evaluate(builder, &block)
389
+ ensure
390
+ @current_fragments = previous_fragments
391
+ @root_builder = previous_root
392
+ end
393
+ else
394
+ evaluate(builder, &block)
395
+ end
396
+ SafeHTML.new(builder.render)
397
+ end
398
+
399
+ def evaluate(builder, &block)
400
+ owner = block.binding.receiver
401
+ owner.instance_variables.each do |ivar|
402
+ next if ivar == :@output || ivar == :@enabled
403
+
404
+ builder.instance_variable_set(ivar, owner.instance_variable_get(ivar))
405
+ end
406
+ if block.lambda? && block.arity.zero?
407
+ builder.instance_exec(&block)
408
+ else
409
+ builder.instance_eval(&block)
410
+ end
411
+ end
412
+ end
metadata ADDED
@@ -0,0 +1,51 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rendry
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - NICE-DEV226
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: |
13
+ Rendry UI is a small DSL for composing HTML with Ruby: explicit markup,
14
+ safe escaping by default, testable components, optional htmx
15
+ interactivity, selective fragments, fragment caching, and component kits —
16
+ with no mandatory JavaScript.
17
+ email:
18
+ - NICE-DEV226@users.noreply.github.com
19
+ executables: []
20
+ extensions: []
21
+ extra_rdoc_files: []
22
+ files:
23
+ - LICENSE
24
+ - README.md
25
+ - lib/rendry.rb
26
+ - lib/rendry/version.rb
27
+ homepage: https://github.com/NICE-DEV226/rendry-ui
28
+ licenses:
29
+ - MIT
30
+ metadata:
31
+ homepage_uri: https://github.com/NICE-DEV226/rendry-ui
32
+ source_code_uri: https://github.com/NICE-DEV226/rendry-ui
33
+ changelog_uri: https://github.com/NICE-DEV226/rendry-ui/releases
34
+ rdoc_options: []
35
+ require_paths:
36
+ - lib
37
+ required_ruby_version: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '3.1'
42
+ required_rubygems_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ requirements: []
48
+ rubygems_version: 3.6.9
49
+ specification_version: 4
50
+ summary: Lightweight server-rendered HTML DSL for Ruby
51
+ test_files: []