rendry 0.1.0 → 0.2.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 (5) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +43 -5
  3. data/lib/rendry/version.rb +1 -1
  4. data/lib/rendry.rb +111 -24
  5. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 020307f7389d6a1dcdde28eb61c2eaf4367c9d1848584d67b7665fe45a9804a5
4
- data.tar.gz: 578e5969022b18f937daa40e5fd6419937c8afb906ee0614b1500bc95820c769
3
+ metadata.gz: 8fda28c5a0d8a7165d77da8ad9eac00950005d67a91905ced6f2db63dceca246
4
+ data.tar.gz: 56072ac6f66932de96031dfea4168313db30f48a708397201463821f08122e0e
5
5
  SHA512:
6
- metadata.gz: e8c0cd070c058b4ea029a9ffd2c831c50ae7547ac3006f65bf282899aec0e089cf09049dcd3996c370a6ca83abc51dd19aeda17a9829755c17c2e7cd6bba9fd8
7
- data.tar.gz: 9aa71174fe35e25263de312dd46ff6dc3af4a6e45ca3dbde64a6d66f61476b6d37061eccf689a96bf6d3420adb009e0bca930c63f6bc8c5e0ea09cf0ba96a899
6
+ metadata.gz: 29490b541d776a03fdab7b7feedf00c5033d8c1e985f01c2c69c7735e4acefa769b6e522c4a3de13289a81bcecc901a053c89f62140393ceedb7546fb3fdc73f
7
+ data.tar.gz: 620c4bb000dfc5b6ce4834bf2156b8b790c3a9480b98f3d6f8866d3a8acbdda87e30652625473ee4c475c4f81c86dac51198e149ad3b04c68947f07dec690d98
data/README.md CHANGED
@@ -20,23 +20,38 @@ puts html
20
20
 
21
21
  Values passed to `text` and element content are escaped automatically. Use `raw` only for an HTML fragment fully controlled by your application.
22
22
 
23
+ A block's return value is appended as content when it is a `String`, `Numeric`, or `Symbol` — so `div { task.title }` works, and strings are escaped unless they are `SafeHTML`.
24
+
25
+ Helpers from the surrounding view context (e.g. Sinatra's `session`, `params`, or your own methods) are reachable inside DSL blocks and are written as escaped text:
26
+
27
+ ```ruby
28
+ get "/" do
29
+ Rendry.render do
30
+ p { flash } # calls your `flash` helper
31
+ session[:count] # calls `session`
32
+ end
33
+ end
34
+ ```
35
+
36
+ `data:` and `aria:` attributes accept nested hashes and are flattened (`data: { toggle: "x" }` → `data-toggle="x"`). URLs with `javascript:`/`vbscript:`/non-image `data:` schemes in `href`, `src`, `action`, `formaction`, or `xlink:href` are rewritten to `#` — disable with `Rendry.sanitize_urls = false`.
37
+
23
38
  ## Components
24
39
 
40
+ The lightest form is a `template` block — no `render` method needed:
41
+
25
42
  ```ruby
26
43
  class Badge < Rendry::Component
27
44
  def initialize(label)
28
45
  @label = label
29
46
  end
30
47
 
31
- def render
32
- Rendry.render { span(@label, class: "badge") }
48
+ template do
49
+ span(@label, class: "badge")
33
50
  end
34
51
  end
35
-
36
- Rendry.render { component Badge.new("Ready") }
37
52
  ```
38
53
 
39
- Components keep access to their instance variables inside DSL blocks, and accept a content block via `do...end` or `{ }`.
54
+ For full control, implement `#render` returning `Rendry.render { ... }` or a `SafeHTML`. Components also receive the view context (`view_context`) when rendered through `component`, so helpers work inside `template` blocks too.
40
55
 
41
56
  ## Features
42
57
 
@@ -120,3 +135,26 @@ The core targets four properties:
120
135
  - no mandatory frontend dependency.
121
136
 
122
137
  This is not a complete web framework yet. Routing, forms, state management, and layout rendering currently belong to the host application, such as Sinatra.
138
+
139
+ ## Contributing
140
+
141
+ How about contributing to the project? I would be truly delighted to have contributors and people who are interested in the project.
142
+
143
+ Rendry is deliberately small — contributions that keep it small are the most welcome:
144
+
145
+ - **Bug reports and fixes** — the DSL has many edge cases (`method_missing` delegation, fragment filtering, context bridging); reproductions are gold.
146
+ - **New built-in components** — `Rendry::Components` is a kit; adding a component is a few lines plus a CSS class in `assets/rendry.css`.
147
+ - **Examples** — a new `examples/NN_name/` mini-project showing a real use case is the best documentation.
148
+ - **Docs** — README sections, yard comments on public API.
149
+
150
+ Please keep the zero-runtime-dependency spirit: `cgi`, `json`, and `set` are the only requires. No frontend build step, no required JS.
151
+
152
+ ```sh
153
+ git clone https://github.com/NICE-DEV226/rendry-ui.git
154
+ cd rendry-ui
155
+ bundle install
156
+ bundle exec rake # run the test suite
157
+ bundle exec rake examples # smoke-run the examples
158
+ ```
159
+
160
+ Open an issue first for anything non-trivial so we can align on direction.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rendry
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/rendry.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "cgi"
4
4
  require "json"
5
+ require "set"
5
6
  require_relative "rendry/version"
6
7
 
7
8
  module Rendry
@@ -11,26 +12,29 @@ module Rendry
11
12
  end
12
13
  end
13
14
 
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
15
+ VOID_ELEMENTS = Set.new(%w[area base br col embed hr img input link meta param source track wbr]).freeze
16
+ BOOLEAN_ATTRIBUTES = Set.new(%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
 
17
18
  class Cache
18
19
  def initialize(limit: 128)
19
20
  @store = {}
20
21
  @limit = limit
22
+ @mutex = Mutex.new
21
23
  end
22
24
 
23
25
  def fetch(key)
24
- return @store[key] if @store.key?(key)
26
+ @mutex.synchronize do
27
+ return @store[key] if @store.key?(key)
25
28
 
26
- value = yield
27
- @store[key] = value
28
- @store.shift while @store.size > @limit
29
- value
29
+ value = yield
30
+ @store[key] = value
31
+ @store.shift while @store.size > @limit
32
+ value
33
+ end
30
34
  end
31
35
 
32
36
  def clear
33
- @store.clear
37
+ @mutex.synchronize { @store.clear }
34
38
  end
35
39
  end
36
40
 
@@ -64,8 +68,16 @@ module Rendry
64
68
 
65
69
  if block
66
70
  children = Builder.new(enabled: @enabled)
67
- Rendry.evaluate(children, &block)
68
- content = SafeHTML.new(children.render)
71
+ result = Rendry.evaluate(children, &block)
72
+ output = children.render
73
+ if Rendry.appendable_result?(result) && !result.is_a?(SafeHTML)
74
+ output = output.dup if output.frozen?
75
+ output << CGI.escapeHTML(result.to_s)
76
+ elsif result.is_a?(SafeHTML)
77
+ output = output.dup if output.frozen?
78
+ output << result
79
+ end
80
+ content = SafeHTML.new(output)
69
81
  end
70
82
 
71
83
  output = +"<#{name}#{format_attributes(attributes)}>"
@@ -113,6 +125,9 @@ module Rendry
113
125
  end
114
126
 
115
127
  def component(component_instance, refresh: nil, &block)
128
+ if component_instance.respond_to?(:view_context=)
129
+ component_instance.view_context = @context
130
+ end
116
131
  if block && component_instance.respond_to?(:block=) && !component_instance.instance_variable_get(:@block)
117
132
  component_instance.block = block
118
133
  end
@@ -134,7 +149,22 @@ module Rendry
134
149
 
135
150
  def method_missing(name, content = nil, **attributes, &block)
136
151
  if name.to_s.match?(/\A[a-z][a-z0-9_]*\z/)
137
- tag(name.to_s.tr("_", "-"), content, **attributes, &block)
152
+ context = @context
153
+ helper = context && context.respond_to?(name) ? context.method(name) : nil
154
+ has_arguments = !(content.nil? && attributes.empty? && !block)
155
+ if helper && !(helper.arity == 0 && has_arguments)
156
+ result = if content.nil? && attributes.empty? && !block
157
+ context.public_send(name)
158
+ elsif content.nil?
159
+ context.public_send(name, **attributes, &block)
160
+ else
161
+ context.public_send(name, content, **attributes, &block)
162
+ end
163
+ write_escaped_result(result)
164
+ self
165
+ else
166
+ tag(name.to_s.tr("_", "-"), content, **attributes, &block)
167
+ end
138
168
  else
139
169
  super
140
170
  end
@@ -150,6 +180,14 @@ module Rendry
150
180
  @output << string if @enabled
151
181
  end
152
182
 
183
+ def write_escaped_result(result)
184
+ if result.is_a?(SafeHTML)
185
+ write result
186
+ elsif Rendry.appendable_result?(result)
187
+ write CGI.escapeHTML(result.to_s)
188
+ end
189
+ end
190
+
153
191
  def refresh_wrapper(html, config)
154
192
  attributes = {
155
193
  class: "rendry-refresh",
@@ -169,7 +207,20 @@ module Rendry
169
207
  attributes.each_with_object(+"") do |(key, value), result|
170
208
  next if value.nil? || value == false
171
209
 
210
+ if (key == :data || key == :aria) && value.is_a?(Hash)
211
+ value.each do |nested_key, nested_value|
212
+ next if nested_value.nil? || nested_value == false
213
+
214
+ result << %( #{key}-#{nested_key.to_s.tr('_', '-')}="#{CGI.escapeHTML(nested_value.to_s)}")
215
+ end
216
+ next
217
+ end
218
+
172
219
  attribute = key.to_s.tr("_", "-")
220
+ if Rendry.sanitize_urls && URL_ATTRIBUTES.include?(attribute) &&
221
+ value.to_s.match?(UNSAFE_URL_SCHEME)
222
+ value = "#"
223
+ end
173
224
  if BOOLEAN_ATTRIBUTES.include?(key.to_sym)
174
225
  result << " #{attribute}"
175
226
  else
@@ -181,6 +232,7 @@ module Rendry
181
232
 
182
233
  class Component
183
234
  attr_writer :block
235
+ attr_accessor :view_context
184
236
 
185
237
  class << self
186
238
  def refreshes(**config)
@@ -190,10 +242,25 @@ module Rendry
190
242
  def refresh_config
191
243
  @refresh_config
192
244
  end
245
+
246
+ def template(&block)
247
+ @template_block = block if block
248
+ @template_block || (superclass.template if superclass.respond_to?(:template))
249
+ end
193
250
  end
194
251
 
195
252
  def render
196
- raise NotImplementedError, "#{self.class} must implement #render"
253
+ block = self.class.template
254
+ raise NotImplementedError, "#{self.class} must implement #render or declare a template" unless block
255
+
256
+ builder = Builder.new
257
+ result = Rendry.evaluate(builder, self, context: view_context, &block)
258
+ if result.is_a?(SafeHTML)
259
+ builder.output << result
260
+ elsif Rendry.appendable_result?(result)
261
+ builder.output << CGI.escapeHTML(result.to_s)
262
+ end
263
+ SafeHTML.new(builder.render)
197
264
  end
198
265
  end
199
266
 
@@ -355,8 +422,23 @@ module Rendry
355
422
  component Alert
356
423
  end
357
424
 
425
+ UNSAFE_URL_SCHEME = /\A\s*(javascript|vbscript|data(?!:image\/))\s*:/i
426
+ URL_ATTRIBUTES = %w[href src action formaction xlink:href].freeze
427
+
358
428
  module_function
359
429
 
430
+ def sanitize_urls
431
+ @sanitize_urls != false
432
+ end
433
+
434
+ def sanitize_urls=(value)
435
+ @sanitize_urls = value
436
+ end
437
+
438
+ def appendable_result?(value)
439
+ value.is_a?(String) || value.is_a?(Numeric) || value.is_a?(Symbol)
440
+ end
441
+
360
442
  def hx(**attributes)
361
443
  attributes.each_with_object({}) do |(key, value), result|
362
444
  value = JSON.generate(value) if value.is_a?(Hash) || value.is_a?(Array)
@@ -369,26 +451,26 @@ module Rendry
369
451
  end
370
452
 
371
453
  def current_fragments
372
- @current_fragments
454
+ Thread.current[:rendry_current_fragments]
373
455
  end
374
456
 
375
457
  def root_builder
376
- @root_builder
458
+ Thread.current[:rendry_root_builder]
377
459
  end
378
460
 
379
461
  def render(fragments: nil, &block)
380
- filter = fragments || @current_fragments
462
+ filter = fragments || current_fragments
381
463
  builder = Builder.new(enabled: filter.nil?)
382
464
  if fragments
383
- previous_fragments = @current_fragments
384
- previous_root = @root_builder
385
- @current_fragments = fragments.map(&:to_s)
386
- @root_builder = builder
465
+ previous_fragments = current_fragments
466
+ previous_root = root_builder
467
+ Thread.current[:rendry_current_fragments] = fragments.map(&:to_s)
468
+ Thread.current[:rendry_root_builder] = builder
387
469
  begin
388
470
  evaluate(builder, &block)
389
471
  ensure
390
- @current_fragments = previous_fragments
391
- @root_builder = previous_root
472
+ Thread.current[:rendry_current_fragments] = previous_fragments
473
+ Thread.current[:rendry_root_builder] = previous_root
392
474
  end
393
475
  else
394
476
  evaluate(builder, &block)
@@ -396,13 +478,18 @@ module Rendry
396
478
  SafeHTML.new(builder.render)
397
479
  end
398
480
 
399
- def evaluate(builder, &block)
400
- owner = block.binding.receiver
481
+ def evaluate(builder, owner = nil, context: nil, &block)
482
+ owner ||= block&.binding&.receiver
483
+ raise ArgumentError, "Rendry.evaluate requires a block" unless block && owner
484
+
485
+ inherited = owner.instance_variable_get(:@context) if owner.is_a?(Builder)
401
486
  owner.instance_variables.each do |ivar|
402
- next if ivar == :@output || ivar == :@enabled
487
+ next if ivar == :@output || ivar == :@enabled || ivar == :@context
403
488
 
404
489
  builder.instance_variable_set(ivar, owner.instance_variable_get(ivar))
405
490
  end
491
+ resolved = context || inherited || (owner unless owner.is_a?(Builder))
492
+ builder.instance_variable_set(:@context, resolved)
406
493
  if block.lambda? && block.arity.zero?
407
494
  builder.instance_exec(&block)
408
495
  else
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rendry
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - NICE-DEV226