rendry 0.1.0 → 0.2.1

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 +101 -25
  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: 043bde156ddb2a1af19b6c8507e676330b4ad40a079cc8850ced810396559dfa
4
+ data.tar.gz: bf349c54a7025081672066beb879e8ca8fa4d5e2eecf81c80c4f23f2cec85d50
5
5
  SHA512:
6
- metadata.gz: e8c0cd070c058b4ea029a9ffd2c831c50ae7547ac3006f65bf282899aec0e089cf09049dcd3996c370a6ca83abc51dd19aeda17a9829755c17c2e7cd6bba9fd8
7
- data.tar.gz: 9aa71174fe35e25263de312dd46ff6dc3af4a6e45ca3dbde64a6d66f61476b6d37061eccf689a96bf6d3420adb009e0bca930c63f6bc8c5e0ea09cf0ba96a899
6
+ metadata.gz: 8912d9c980908d713576dc50307c2ef7b9bd09a9f0be7a3072ea8507b6d491c6af0236f84a428f9adc384702195fdfb6a966322a9f5422f709462699a9ad910b
7
+ data.tar.gz: 1dd76f09af3eccbf79b653abcbc0c1371b27a4f281699a35157264244e637799ac1124d50dfb8343fde8866225a2401d13de16dae7c39502ddb5a87780067e6d
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.1"
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,7 +68,8 @@ module Rendry
64
68
 
65
69
  if block
66
70
  children = Builder.new(enabled: @enabled)
67
- Rendry.evaluate(children, &block)
71
+ result = Rendry.evaluate(children, &block)
72
+ children.append_result(result)
68
73
  content = SafeHTML.new(children.render)
69
74
  end
70
75
 
@@ -113,6 +118,9 @@ module Rendry
113
118
  end
114
119
 
115
120
  def component(component_instance, refresh: nil, &block)
121
+ if component_instance.respond_to?(:view_context=)
122
+ component_instance.view_context = @context
123
+ end
116
124
  if block && component_instance.respond_to?(:block=) && !component_instance.instance_variable_get(:@block)
117
125
  component_instance.block = block
118
126
  end
@@ -134,7 +142,20 @@ module Rendry
134
142
 
135
143
  def method_missing(name, content = nil, **attributes, &block)
136
144
  if name.to_s.match?(/\A[a-z][a-z0-9_]*\z/)
137
- tag(name.to_s.tr("_", "-"), content, **attributes, &block)
145
+ context = @context
146
+ helper = context && context.respond_to?(name) ? context.method(name) : nil
147
+ has_arguments = !(content.nil? && attributes.empty? && !block)
148
+ if helper && !(helper.arity == 0 && has_arguments)
149
+ if content.nil? && attributes.empty? && !block
150
+ context.public_send(name)
151
+ elsif content.nil?
152
+ context.public_send(name, **attributes, &block)
153
+ else
154
+ context.public_send(name, content, **attributes, &block)
155
+ end
156
+ else
157
+ tag(name.to_s.tr("_", "-"), content, **attributes, &block)
158
+ end
138
159
  else
139
160
  super
140
161
  end
@@ -144,6 +165,15 @@ module Rendry
144
165
  name.to_s.match?(/\A[a-z][a-z0-9_]*\z/) || super
145
166
  end
146
167
 
168
+ def append_result(result)
169
+ if result.is_a?(SafeHTML)
170
+ write result
171
+ elsif Rendry.appendable_result?(result)
172
+ write CGI.escapeHTML(result.to_s)
173
+ end
174
+ self
175
+ end
176
+
147
177
  private
148
178
 
149
179
  def write(string)
@@ -169,7 +199,20 @@ module Rendry
169
199
  attributes.each_with_object(+"") do |(key, value), result|
170
200
  next if value.nil? || value == false
171
201
 
202
+ if (key == :data || key == :aria) && value.is_a?(Hash)
203
+ value.each do |nested_key, nested_value|
204
+ next if nested_value.nil? || nested_value == false
205
+
206
+ result << %( #{key}-#{nested_key.to_s.tr('_', '-')}="#{CGI.escapeHTML(nested_value.to_s)}")
207
+ end
208
+ next
209
+ end
210
+
172
211
  attribute = key.to_s.tr("_", "-")
212
+ if Rendry.sanitize_urls && URL_ATTRIBUTES.include?(attribute) &&
213
+ value.to_s.match?(UNSAFE_URL_SCHEME)
214
+ value = "#"
215
+ end
173
216
  if BOOLEAN_ATTRIBUTES.include?(key.to_sym)
174
217
  result << " #{attribute}"
175
218
  else
@@ -181,6 +224,7 @@ module Rendry
181
224
 
182
225
  class Component
183
226
  attr_writer :block
227
+ attr_accessor :view_context
184
228
 
185
229
  class << self
186
230
  def refreshes(**config)
@@ -190,10 +234,21 @@ module Rendry
190
234
  def refresh_config
191
235
  @refresh_config
192
236
  end
237
+
238
+ def template(&block)
239
+ @template_block = block if block
240
+ @template_block || (superclass.template if superclass.respond_to?(:template))
241
+ end
193
242
  end
194
243
 
195
244
  def render
196
- raise NotImplementedError, "#{self.class} must implement #render"
245
+ block = self.class.template
246
+ raise NotImplementedError, "#{self.class} must implement #render or declare a template" unless block
247
+
248
+ builder = Builder.new
249
+ result = Rendry.evaluate(builder, self, context: view_context, &block)
250
+ builder.append_result(result)
251
+ SafeHTML.new(builder.render)
197
252
  end
198
253
  end
199
254
 
@@ -355,8 +410,23 @@ module Rendry
355
410
  component Alert
356
411
  end
357
412
 
413
+ UNSAFE_URL_SCHEME = /\A\s*(javascript|vbscript|data(?!:image\/))\s*:/i
414
+ URL_ATTRIBUTES = %w[href src action formaction xlink:href].freeze
415
+
358
416
  module_function
359
417
 
418
+ def sanitize_urls
419
+ @sanitize_urls != false
420
+ end
421
+
422
+ def sanitize_urls=(value)
423
+ @sanitize_urls = value
424
+ end
425
+
426
+ def appendable_result?(value)
427
+ value.is_a?(String) || value.is_a?(Numeric) || value.is_a?(Symbol)
428
+ end
429
+
360
430
  def hx(**attributes)
361
431
  attributes.each_with_object({}) do |(key, value), result|
362
432
  value = JSON.generate(value) if value.is_a?(Hash) || value.is_a?(Array)
@@ -369,40 +439,46 @@ module Rendry
369
439
  end
370
440
 
371
441
  def current_fragments
372
- @current_fragments
442
+ Thread.current[:rendry_current_fragments]
373
443
  end
374
444
 
375
445
  def root_builder
376
- @root_builder
446
+ Thread.current[:rendry_root_builder]
377
447
  end
378
448
 
379
449
  def render(fragments: nil, &block)
380
- filter = fragments || @current_fragments
450
+ filter = fragments || current_fragments
381
451
  builder = Builder.new(enabled: filter.nil?)
382
452
  if fragments
383
- previous_fragments = @current_fragments
384
- previous_root = @root_builder
385
- @current_fragments = fragments.map(&:to_s)
386
- @root_builder = builder
453
+ previous_fragments = current_fragments
454
+ previous_root = root_builder
455
+ Thread.current[:rendry_current_fragments] = fragments.map(&:to_s)
456
+ Thread.current[:rendry_root_builder] = builder
387
457
  begin
388
- evaluate(builder, &block)
458
+ result = evaluate(builder, &block)
389
459
  ensure
390
- @current_fragments = previous_fragments
391
- @root_builder = previous_root
460
+ Thread.current[:rendry_current_fragments] = previous_fragments
461
+ Thread.current[:rendry_root_builder] = previous_root
392
462
  end
393
463
  else
394
- evaluate(builder, &block)
464
+ result = evaluate(builder, &block)
395
465
  end
466
+ builder.append_result(result)
396
467
  SafeHTML.new(builder.render)
397
468
  end
398
469
 
399
- def evaluate(builder, &block)
400
- owner = block.binding.receiver
470
+ def evaluate(builder, owner = nil, context: nil, &block)
471
+ owner ||= block&.binding&.receiver
472
+ raise ArgumentError, "Rendry.evaluate requires a block" unless block && owner
473
+
474
+ inherited = owner.instance_variable_get(:@context) if owner.is_a?(Builder)
401
475
  owner.instance_variables.each do |ivar|
402
- next if ivar == :@output || ivar == :@enabled
476
+ next if ivar == :@output || ivar == :@enabled || ivar == :@context
403
477
 
404
478
  builder.instance_variable_set(ivar, owner.instance_variable_get(ivar))
405
479
  end
480
+ resolved = context || inherited || (owner unless owner.is_a?(Builder))
481
+ builder.instance_variable_set(:@context, resolved)
406
482
  if block.lambda? && block.arity.zero?
407
483
  builder.instance_exec(&block)
408
484
  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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - NICE-DEV226