ruby2html 1.6.6 → 2.0.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.
@@ -1,108 +1,16 @@
1
- // ext/ruby2html/ruby2html.c
2
- #include <ruby.h>
3
- #include <ruby/encoding.h>
4
- #include <ctype.h>
5
-
6
- static VALUE rb_mRuby2html;
7
- static VALUE rb_cRenderer;
8
-
9
- // Fast HTML escaping
10
- static VALUE fast_escape_html(VALUE self, VALUE str) {
11
- if (NIL_P(str)) return Qnil;
12
-
13
- str = rb_String(str);
14
- long len = RSTRING_LEN(str);
15
- const char *ptr = RSTRING_PTR(str);
16
-
17
- // First pass: calculate required buffer size
18
- long new_len = len;
19
- for (long i = 0; i < len; i++) {
20
- switch (ptr[i]) {
21
- case '&': new_len += 4; break; // &amp;
22
- case '<': new_len += 3; break; // &lt;
23
- case '>': new_len += 3; break; // &gt;
24
- case '"': new_len += 5; break; // &quot;
25
- case '\'': new_len += 5; break; // &#39;
26
- }
27
- }
28
-
29
- if (new_len == len) return str;
30
-
31
- VALUE result = rb_str_new(NULL, new_len);
32
- char *out = RSTRING_PTR(result);
33
- long pos = 0;
34
-
35
- // Second pass: actual escaping
36
- for (long i = 0; i < len; i++) {
37
- switch (ptr[i]) {
38
- case '&':
39
- memcpy(out + pos, "&amp;", 5);
40
- pos += 5;
41
- break;
42
- case '<':
43
- memcpy(out + pos, "&lt;", 4);
44
- pos += 4;
45
- break;
46
- case '>':
47
- memcpy(out + pos, "&gt;", 4);
48
- pos += 4;
49
- break;
50
- case '"':
51
- memcpy(out + pos, "&quot;", 6);
52
- pos += 6;
53
- break;
54
- case '\'':
55
- memcpy(out + pos, "&#39;", 5);
56
- pos += 5;
57
- break;
58
- default:
59
- out[pos++] = ptr[i];
60
- }
61
- }
62
-
63
- rb_str_set_len(result, pos);
64
- rb_enc_associate(result, rb_enc_get(str));
65
- return result;
1
+ /*
2
+ * Native renderer core. Defines Ruby2html::Native, a module of instance
3
+ * methods (tag methods, plain, component) that Ruby2html::Render includes.
4
+ * Ruby2html::PureRuby (lib/gem/ruby2html/render/pure_ruby.rb) mirrors it.
5
+ */
6
+ #include "ruby2html.h"
7
+
8
+ RUBY_FUNC_EXPORTED void Init_ruby2html(void)
9
+ {
10
+ VALUE ruby2html = rb_define_module("Ruby2html");
11
+ VALUE native = rb_define_module_under(ruby2html, "Native");
12
+
13
+ r2h_init_escape(native);
14
+ r2h_init_content(native);
15
+ r2h_init_tags(native);
66
16
  }
67
-
68
- // Fast attribute string builder
69
- static VALUE fast_attributes_to_s(VALUE self, VALUE hash) {
70
- if (NIL_P(hash) || RHASH_EMPTY_P(hash)) return rb_str_new2("");
71
-
72
- VALUE result = rb_str_buf_new(64); // Pre-allocate with reasonable size
73
- VALUE keys = rb_funcall(hash, rb_intern("keys"), 0);
74
- long len = RARRAY_LEN(keys);
75
-
76
- for (long i = 0; i < len; i++) {
77
- VALUE key = rb_ary_entry(keys, i);
78
- VALUE value = rb_hash_aref(hash, key);
79
-
80
- if (!NIL_P(value)) {
81
- rb_str_cat2(result, " ");
82
- rb_str_append(result, rb_String(key));
83
- rb_str_cat2(result, "=\"");
84
- rb_str_append(result, fast_escape_html(self, rb_String(value)));
85
- rb_str_cat2(result, "\"");
86
- }
87
- }
88
-
89
- return result;
90
- }
91
-
92
- // Fast string buffer concatenation
93
- static VALUE fast_buffer_append(VALUE self, VALUE buffer, VALUE str) {
94
- if (!NIL_P(str)) {
95
- rb_funcall(buffer, rb_intern("<<"), 1, rb_String(str));
96
- }
97
- return Qnil;
98
- }
99
-
100
- void Init_ruby2html(void) {
101
- rb_mRuby2html = rb_define_module("Ruby2html");
102
-
103
- rb_cRenderer = rb_define_class_under(rb_mRuby2html, "Render", rb_cObject);
104
-
105
- rb_define_method(rb_cRenderer, "fast_escape_html", fast_escape_html, 1);
106
- rb_define_method(rb_cRenderer, "fast_attributes_to_s", fast_attributes_to_s, 1);
107
- rb_define_method(rb_cRenderer, "fast_buffer_append", fast_buffer_append, 2);
108
- }
@@ -0,0 +1,32 @@
1
+ #ifndef RUBY2HTML_H
2
+ #define RUBY2HTML_H
3
+
4
+ #include <ruby.h>
5
+ #include <ruby/encoding.h>
6
+
7
+ /* Interned at Init so hot paths never call rb_intern. */
8
+ extern ID r2h_id_current_output;
9
+ extern ID r2h_id_html_safe_p;
10
+
11
+ /* html_escape.c */
12
+ void r2h_init_escape(VALUE native);
13
+ VALUE r2h_utf8_compatible(VALUE str);
14
+ void r2h_append_escaped(VALUE buf, VALUE str);
15
+ void r2h_append_escaped_wrapped(VALUE buf, const char *before, long before_len, VALUE str,
16
+ const char *after, long after_len);
17
+ void r2h_append_raw(VALUE buf, VALUE str);
18
+
19
+ /* attributes.c */
20
+ void r2h_append_attributes(VALUE buf, VALUE attrs);
21
+
22
+ /* content.c */
23
+ void r2h_init_content(VALUE native);
24
+ VALUE r2h_current_buffer(VALUE self);
25
+ void r2h_append_long(VALUE buf, long value);
26
+ void r2h_append_text(VALUE buf, VALUE obj);
27
+ void r2h_append_block_result(VALUE buf, VALUE result);
28
+
29
+ /* tag_render.c */
30
+ void r2h_init_tags(VALUE native);
31
+
32
+ #endif
@@ -0,0 +1,179 @@
1
+ /*
2
+ * Tag methods (div, p, img, ...) implemented in C.
3
+ *
4
+ * Each tag is a C method, so a call costs no Ruby frame, no *args or **kwargs
5
+ * allocation when called without keywords, and block content is rendered
6
+ * straight into the shared buffer (open tag, yield, close tag) instead of
7
+ * into a nested buffer that is copied afterwards.
8
+ *
9
+ * The tag list itself lives in Ruby (Render::HTML5_TAGS). A C method can't
10
+ * carry data, so define_tag binds each tag to a slot of a fixed pool of
11
+ * one-line trampolines, each of which passes its slot to render_tag.
12
+ */
13
+ #include "buffer.h"
14
+
15
+ #define R2H_MAX_TAGS 200
16
+ #define R2H_MAX_TAG_NAME 32
17
+
18
+ typedef struct {
19
+ char open[R2H_MAX_TAG_NAME + 5]; /* "<div>", or "<br />" for void tags, + NUL */
20
+ char close[R2H_MAX_TAG_NAME + 4]; /* "</div>" + NUL */
21
+ long open_len;
22
+ long name_len; /* "<div" is open[0, name_len + 1) */
23
+ long close_len;
24
+ int is_void;
25
+ ID method;
26
+ } r2h_tag;
27
+
28
+ static r2h_tag tags[R2H_MAX_TAGS];
29
+ static int tag_count;
30
+
31
+ typedef struct {
32
+ VALUE buf;
33
+ VALUE attrs;
34
+ } attrs_args;
35
+
36
+ static VALUE append_attributes_protected(VALUE data)
37
+ {
38
+ const attrs_args *args = (const attrs_args *)data;
39
+ r2h_append_attributes(args->buf, args->attrs);
40
+ return Qnil;
41
+ }
42
+
43
+ static void open_tag(VALUE buf, const r2h_tag *tag, VALUE attrs)
44
+ {
45
+ if (NIL_P(attrs)) {
46
+ r2h_buf_cat(buf, tag->open, tag->open_len);
47
+ return;
48
+ }
49
+ /*
50
+ * If an attribute raises (invalid name, a value's to_s raising) and the
51
+ * caller rescues, a half-written `<div class="x` would turn whatever is
52
+ * appended next into markup. Roll the buffer back to before the tag.
53
+ */
54
+ const long mark = RSTRING_LEN(buf);
55
+ r2h_buf_cat(buf, tag->open, tag->name_len + 1);
56
+ attrs_args args = { buf, attrs };
57
+ int state = 0;
58
+ rb_protect(append_attributes_protected, (VALUE)&args, &state);
59
+ if (state) {
60
+ /*
61
+ * rb_str_resize (not set_len): the failing to_s may have replaced,
62
+ * shared or shrunk the buffer. Leave a frozen buffer alone so the
63
+ * original exception propagates.
64
+ */
65
+ if (!OBJ_FROZEN(buf) && RSTRING_LEN(buf) >= mark) rb_str_resize(buf, mark);
66
+ rb_jump_tag(state);
67
+ }
68
+ if (tag->is_void) {
69
+ R2H_BUF_CAT_LIT(buf, " />");
70
+ } else {
71
+ R2H_BUF_CAT_LIT(buf, ">");
72
+ }
73
+ }
74
+
75
+ /*
76
+ * tag(content = nil, **attributes) { ... }
77
+ *
78
+ * With a block, the block's output is the content (content is ignored);
79
+ * void tags ignore content and block.
80
+ */
81
+ static VALUE render_tag(int argc, VALUE *argv, VALUE self, const r2h_tag *tag)
82
+ {
83
+ VALUE attrs = Qnil, content = Qnil;
84
+
85
+ if (argc > 0 && rb_keyword_given_p()) {
86
+ attrs = argv[--argc];
87
+ if (RHASH_EMPTY_P(attrs)) attrs = Qnil;
88
+ }
89
+ if (argc > 1) rb_error_arity(argc, 0, 1);
90
+ if (argc == 1) content = argv[0];
91
+
92
+ VALUE buf = r2h_current_buffer(self);
93
+ const int block_given = rb_block_given_p();
94
+
95
+ /* Fast path for the most common shape, `li "text"`: one buffer write. */
96
+ if (NIL_P(attrs) && !block_given && !tag->is_void && RB_TYPE_P(content, T_STRING) &&
97
+ RBASIC_CLASS(content) == rb_cString) {
98
+ r2h_append_escaped_wrapped(buf, tag->open, tag->open_len, content, tag->close, tag->close_len);
99
+ return Qnil;
100
+ }
101
+
102
+ open_tag(buf, tag, attrs);
103
+ if (tag->is_void) return Qnil;
104
+
105
+ if (block_given) {
106
+ const long before = RSTRING_LEN(buf);
107
+ VALUE result = rb_yield_values2(0, NULL);
108
+ if (RSTRING_LEN(buf) == before) r2h_append_block_result(buf, result);
109
+ } else {
110
+ r2h_append_text(buf, content);
111
+ }
112
+ r2h_buf_cat(buf, tag->close, tag->close_len);
113
+ RB_GC_GUARD(buf);
114
+ return Qnil;
115
+ }
116
+
117
+ #define R2H_TRAMPOLINE(n) \
118
+ static VALUE tag_##n(int argc, VALUE *argv, VALUE self) { return render_tag(argc, argv, self, &tags[n]); }
119
+ #define R2H_TRAMPOLINE_REF(n) tag_##n,
120
+ #define R2H_TEN(M, d) M(d##0) M(d##1) M(d##2) M(d##3) M(d##4) M(d##5) M(d##6) M(d##7) M(d##8) M(d##9)
121
+ #define R2H_TWO_HUNDRED(M) \
122
+ R2H_TEN(M, ) R2H_TEN(M, 1) R2H_TEN(M, 2) R2H_TEN(M, 3) R2H_TEN(M, 4) R2H_TEN(M, 5) R2H_TEN(M, 6) \
123
+ R2H_TEN(M, 7) R2H_TEN(M, 8) R2H_TEN(M, 9) R2H_TEN(M, 10) R2H_TEN(M, 11) R2H_TEN(M, 12) R2H_TEN(M, 13) \
124
+ R2H_TEN(M, 14) R2H_TEN(M, 15) R2H_TEN(M, 16) R2H_TEN(M, 17) R2H_TEN(M, 18) R2H_TEN(M, 19)
125
+
126
+ R2H_TWO_HUNDRED(R2H_TRAMPOLINE)
127
+
128
+ typedef VALUE (*r2h_tag_fn)(int, VALUE *, VALUE);
129
+ static const r2h_tag_fn trampolines[R2H_MAX_TAGS] = { R2H_TWO_HUNDRED(R2H_TRAMPOLINE_REF) };
130
+
131
+ /* Tag names are written unescaped, so only allow [a-z0-9-]. */
132
+ static void validate_tag_name(VALUE name)
133
+ {
134
+ const char *p = RSTRING_PTR(name);
135
+ const long len = RSTRING_LEN(name);
136
+
137
+ if (len == 0 || len > R2H_MAX_TAG_NAME) rb_raise(rb_eArgError, "invalid tag name: %+" PRIsVALUE, name);
138
+ for (long i = 0; i < len; i++) {
139
+ const char c = p[i];
140
+ if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-')) {
141
+ rb_raise(rb_eArgError, "invalid tag name: %+" PRIsVALUE, name);
142
+ }
143
+ }
144
+ }
145
+
146
+ static r2h_tag *slot_for(ID method)
147
+ {
148
+ for (int i = 0; i < tag_count; i++) {
149
+ if (tags[i].method == method) return &tags[i];
150
+ }
151
+ if (tag_count == R2H_MAX_TAGS) rb_raise(rb_eRuntimeError, "ruby2html: more than %d tags", R2H_MAX_TAGS);
152
+ return &tags[tag_count++];
153
+ }
154
+
155
+ /* Ruby2html::Native.define_tag(method_name, tag_name, void) */
156
+ static VALUE native_define_tag(VALUE native, VALUE method_name, VALUE tag_name, VALUE is_void)
157
+ {
158
+ const ID method = rb_to_id(method_name);
159
+ StringValue(tag_name);
160
+ validate_tag_name(tag_name);
161
+
162
+ r2h_tag *tag = slot_for(method);
163
+ const long len = RSTRING_LEN(tag_name);
164
+ tag->method = method;
165
+ tag->is_void = RTEST(is_void);
166
+ tag->name_len = len;
167
+ tag->open_len = snprintf(tag->open, sizeof(tag->open), tag->is_void ? "<%.*s />" : "<%.*s>", (int)len,
168
+ RSTRING_PTR(tag_name));
169
+ tag->close_len = snprintf(tag->close, sizeof(tag->close), "</%.*s>", (int)len, RSTRING_PTR(tag_name));
170
+
171
+ /* rb_define_method (not _id): TruffleRuby doesn't implement rb_define_method_id. */
172
+ rb_define_method(native, rb_id2name(method), trampolines[tag - tags], -1);
173
+ return Qnil;
174
+ }
175
+
176
+ void r2h_init_tags(VALUE native)
177
+ {
178
+ rb_define_singleton_method(native, "define_tag", native_define_tag, 3);
179
+ }
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'render'
4
+
5
+ module Ruby2html
6
+ # Lightweight component without ViewComponent:
7
+ #
8
+ # class CardComponent < Ruby2html::Component
9
+ # def initialize(title:) = @title = title
10
+ #
11
+ # def view_template
12
+ # div class: 'card' do
13
+ # h2 @title
14
+ # content # block given to render, if any
15
+ # link_to 'More', helpers.root_path
16
+ # end
17
+ # end
18
+ # end
19
+ #
20
+ # render CardComponent.new(title: 'Hi') # ERB, controllers, .html.rb
21
+ #
22
+ # Why it is fast: the tag methods are mixed straight into the component and
23
+ # write into a buffer it is handed, so templates run with +self+ = the
24
+ # component (own ivars, no copying, no instance_exec, no method_missing).
25
+ # Nested in another Ruby2html component or .html.rb template it writes into
26
+ # the parent's buffer directly; only the outermost render allocates one.
27
+ #
28
+ # Define +view_template+ (or +call+). +template+ is accepted when a subclass
29
+ # overrides it, but shadows the <template> tag for that component.
30
+ class Component
31
+ include Ruby2html::IMPLEMENTATION
32
+
33
+ INITIAL_CAPACITY = 1024
34
+
35
+ # Rails helpers whose markup must land in the component's buffer. The
36
+ # component itself is the RailsComponents "renderer" (it has #plain and
37
+ # #current_output). Constants are resolved lazily: they need ActionView.
38
+ Render::RAILS_HELPER_COMPONENTS.each do |name, klass|
39
+ define_method(name) do |*args, **options, &block|
40
+ RailsComponents.const_get(klass).new(self, helpers, name, *args, **options).render(&block)
41
+ end
42
+ end
43
+
44
+ class << self
45
+ # Entry method, resolved once per class.
46
+ def __r2h_entry
47
+ @__r2h_entry ||=
48
+ if method_defined?(:view_template) then :view_template
49
+ elsif method_defined?(:call) then :call
50
+ elsif instance_method(:template).owner != Ruby2html::IMPLEMENTATION then :template
51
+ else raise NotImplementedError, "#{name} must define #view_template (or #call)"
52
+ end
53
+ end
54
+
55
+ # Last output size, so the next top-level render pre-sizes its buffer.
56
+ # A benign race between threads only affects the capacity hint.
57
+ attr_writer :__r2h_capacity
58
+
59
+ def __r2h_capacity
60
+ @__r2h_capacity || INITIAL_CAPACITY
61
+ end
62
+ end
63
+
64
+ # The buffer currently written to (RailsComponents swap it to capture blocks).
65
+ attr_accessor :current_output
66
+
67
+ # Rails' entry point: `render component` in views and controllers.
68
+ def render_in(view_context, &block)
69
+ buffer = String.new(capacity: self.class.__r2h_capacity, encoding: Encoding::UTF_8)
70
+ content = block && -> { plain(view_context.capture(self, &block)) }
71
+ render_into(buffer, view_context, content)
72
+ self.class.__r2h_capacity = buffer.bytesize if buffer.bytesize > self.class.__r2h_capacity
73
+ defined?(ActiveSupport::SafeBuffer) ? ActiveSupport::SafeBuffer.new(buffer) : buffer
74
+ end
75
+
76
+ # Writes the component into +buffer+ (a parent's output) and returns nil.
77
+ # +content+ is a callable run by #content. Re-entrant: state is restored.
78
+ def render_into(buffer, view_context, content = nil)
79
+ previous_output = @current_output
80
+ previous_helpers = @__r2h_helpers
81
+ previous_content = @__r2h_content
82
+ @current_output = buffer
83
+ @__r2h_helpers = view_context
84
+ @__r2h_content = content
85
+ __send__(self.class.__r2h_entry)
86
+ nil
87
+ ensure
88
+ @current_output = previous_output
89
+ @__r2h_helpers = previous_helpers
90
+ @__r2h_content = previous_content
91
+ end
92
+
93
+ # The view context (Rails helpers, routes, request, ...).
94
+ def helpers
95
+ return @__r2h_helpers if @__r2h_helpers
96
+
97
+ raise Ruby2html::Error, "#{self.class.name}#helpers is only available while rendering"
98
+ end
99
+
100
+ # Emits the block given to render (see #render_in / #render).
101
+ def content
102
+ @__r2h_content&.call
103
+ nil
104
+ end
105
+
106
+ # Renders another component into this buffer, or anything Rails can
107
+ # render (partials, ViewComponents) via the view context.
108
+ def render(*args, **options, &block)
109
+ first = args.first
110
+ if args.size == 1 && options.empty? && first.is_a?(Component)
111
+ first.render_into(@current_output, helpers, block)
112
+ else
113
+ plain(helpers.render(*args, **options, &block))
114
+ end
115
+ nil
116
+ end
117
+ end
118
+ end
@@ -1,27 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruby2html
4
+ # Mixed into ViewComponents (or any object) to render with Ruby2html.
5
+ #
6
+ # The active renderer is stored on the component itself (+@__r2h+), never in
7
+ # thread/fiber-global state, so interleaved renders (fiber schedulers,
8
+ # Falcon, async) can't see each other's renderer. Component methods called
9
+ # while rendering (e.g. a helper that emits +div+) reach it through
10
+ # method_missing. +.html.rb+ templates set the same ivar on their view or
11
+ # component (see TemplateHandler).
12
+ #
13
+ # Each render saves the previous value and restores it in +ensure+, so
14
+ # re-entrant +html+ calls and exceptions leave no stale renderer behind.
15
+ # The ivar starts with '@_' so Render does not copy it into itself.
4
16
  module ComponentHelper
5
17
  def html(&block)
6
- previous_renderer = __ruby2html_renderer__
7
- Ruby2html::Render.new(self, &block).yield_self do |component_renderer|
8
- Thread.current[:__ruby2html_renderer__] = component_renderer
9
- component_renderer.render.html_safe
10
- end
18
+ previous_renderer = @__r2h
19
+ renderer = Ruby2html::Render.new(self, &block)
20
+ @__r2h = renderer
21
+ output = renderer.render
22
+ # Render#render already returns a SafeBuffer under Rails; avoid copying it.
23
+ output.html_safe? ? output : output.html_safe
11
24
  ensure
12
- Thread.current[:__ruby2html_renderer__] = previous_renderer
13
- end
14
-
15
- def method_missing(method, *args, **options, &block)
16
- if __ruby2html_renderer__.respond_to?(method)
17
- __ruby2html_renderer__.send(method, *args, **options, &block)
18
- else
19
- super
20
- end
25
+ @__r2h = previous_renderer
21
26
  end
22
27
 
28
+ # The renderer currently rendering this object, or nil.
23
29
  def __ruby2html_renderer__
24
- Thread.current[:__ruby2html_renderer__]
30
+ @__r2h
25
31
  end
32
+
33
+ private
34
+ def method_missing(name, ...)
35
+ renderer = @__r2h
36
+ return super if renderer.nil? || !renderer.respond_to?(name)
37
+
38
+ renderer.__send__(name, ...)
39
+ end
40
+
41
+ # Only claims the renderer's own methods (tags, plain, component, ...).
42
+ # Asking the renderer's respond_to? here could recurse back into this
43
+ # object, because the renderer delegates respond_to? to its context.
44
+ def respond_to_missing?(name, include_private = false)
45
+ (@__r2h && Ruby2html::Render.method_defined?(name)) || super
46
+ end
26
47
  end
27
48
  end
@@ -1,24 +1,70 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruby2html
4
+ # Development/test middleware that re-indents HTML responses with
5
+ # HtmlBeautifier. Responses are left untouched unless they are buffered,
6
+ # uncompressed text/html with a body.
4
7
  class HtmlBeautifierMiddleware
8
+ CONTENT_TYPE = 'content-type'
9
+ CONTENT_LENGTH = 'content-length'
10
+ CONTENT_ENCODING = 'content-encoding'
11
+ TRANSFER_ENCODING = 'transfer-encoding'
12
+
5
13
  def initialize(app)
14
+ self.class.load_beautifier
6
15
  @app = app
7
16
  end
8
17
 
18
+ # htmlbeautifier is an optional dependency, only needed by this middleware.
19
+ def self.load_beautifier
20
+ require 'htmlbeautifier'
21
+ rescue LoadError => e
22
+ raise LoadError, "Ruby2html::HtmlBeautifierMiddleware needs the htmlbeautifier gem. Add `gem 'htmlbeautifier'` " \
23
+ "to your Gemfile (e.g. in the :development group) or remove the middleware. (#{e.message})"
24
+ end
25
+
9
26
  def call(env)
10
27
  status, headers, body = @app.call(env)
28
+ return [status, headers, body] unless beautify?(status, headers, body)
11
29
 
12
- if headers['Content-Type']&.include?('text/html')
13
- new_body = []
14
- body.each do |chunk|
15
- new_body << HtmlBeautifier.beautify(chunk)
16
- end
17
- headers['Content-Length'] = new_body.map(&:bytesize).sum.to_s
18
- body = new_body
30
+ html = HtmlBeautifier.beautify(read_body(body))
31
+ set_header(headers, CONTENT_LENGTH, html.bytesize.to_s)
32
+ [status, headers, [html]]
33
+ end
34
+
35
+ private
36
+ def beautify?(status, headers, body)
37
+ status >= 200 && status != 204 && status != 304 &&
38
+ header(headers, CONTENT_TYPE)&.include?('text/html') &&
39
+ header(headers, CONTENT_ENCODING).nil? && # gzip etc.: bytes aren't HTML
40
+ header(headers, TRANSFER_ENCODING).nil? && # streamed by the app
41
+ body.respond_to?(:to_ary) # Rack 3: only buffered bodies; never drain a stream
19
42
  end
20
43
 
21
- [status, headers, body]
22
- end
44
+ # Beautify the document as a whole: chunk boundaries can fall inside tags.
45
+ # Per Rack spec the original body must be closed once consumed.
46
+ def read_body(body)
47
+ chunks = body.to_ary
48
+ chunks.size == 1 ? chunks.first.to_s : chunks.join
49
+ ensure
50
+ body.close if body.respond_to?(:close)
51
+ end
52
+
53
+ # Rack 3 headers are lowercase (and Rails uses case-insensitive
54
+ # Rack::Headers); fall back to a scan for plain hashes from older apps.
55
+ def header(headers, name)
56
+ key = header_key(headers, name)
57
+ key && headers[key]
58
+ end
59
+
60
+ def set_header(headers, name, value)
61
+ headers[header_key(headers, name) || name] = value
62
+ end
63
+
64
+ def header_key(headers, name)
65
+ return name if headers.key?(name)
66
+
67
+ headers.each_key.find { |key| key.casecmp?(name) }
68
+ end
23
69
  end
24
70
  end
@@ -1,8 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'output_capture'
4
+
3
5
  module Ruby2html
4
6
  module RailsComponents
7
+ # Calls the Rails helper named +method+ on the view context and appends its
8
+ # (html_safe) result to the renderer. A block given to the helper is
9
+ # captured from the renderer's buffer, so nested Ruby2html tags end up
10
+ # inside the helper's markup.
5
11
  class BaseComponent
12
+ include OutputCapture
13
+
6
14
  def initialize(render, context, method, *args, **options)
7
15
  @render = render
8
16
  @context = context
@@ -12,7 +20,8 @@ module Ruby2html
12
20
  end
13
21
 
14
22
  def render(&block)
15
- raise NotImplementedError
23
+ @render.plain(@context.public_send(@method, *@args, **@options, &capturing(block)))
24
+ nil
16
25
  end
17
26
  end
18
27
  end
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'base_component'
4
+
3
5
  module Ruby2html
4
6
  module RailsComponents
7
+ # Delegates to Rails' +button_to+; see BaseComponent.
5
8
  class ButtonTo < BaseComponent
6
- def render(&block)
7
- @render.plain(@context.button_to(*@args, **@options, &block))
8
- end
9
9
  end
10
10
  end
11
11
  end