repolho 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2950c91544adda9db0b125b7b73717f20ac7d6535a9f378789c6aa3dadacd59c
4
+ data.tar.gz: dc205200e80ddee22718e5964ce121001da131b7c7545d62cdb4505344b71620
5
+ SHA512:
6
+ metadata.gz: d63f3ef00ee5d6ccf72ed10331c09445672150ea41a977ba24cb90e12a321022a10e372aece79db3ac2c23e648fffd3fc77534062067c74ccbb019c3428adc25
7
+ data.tar.gz: 18dc8cc1965a0a5d020f294a6ff47e581a7dbf0008a9eff2d7fb413d410dcea4ef635d806a32fb74207318788782db32a86b676d92689077793b6d7b416b0229
checksums.yaml.gz.sig ADDED
@@ -0,0 +1,3 @@
1
+ ��0��x�o�PM�'���Q4:jp{�Y�V3]������Gj��k�B�]f�/$P�a'r�>ۧ��u�L.W_!
2
+ �����%GD�A2�кT��"�`e����C� Fc�5��/����g���@���J7[�c=4�!V�Y�ֻ� ���@�0]�o�|+b�����_�� ����V���(����©�\
3
+ 8���]8���إ��)fݨ�Y�����,���反ˮ섮�z���7
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eustáquio Rangel
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,197 @@
1
+ # Repolho
2
+
3
+ ![Logo](logo.png)
4
+
5
+ Report generator for Ruby and Rails.
6
+
7
+ Given a `.rep.erb` template and a data source (ActiveRecord `exec_query`,
8
+ Relation, array of models, or array of hashes), Repolho produces HTML with:
9
+
10
+ - Nested groupings (`<report-group>`)
11
+ - Page breaks (`<report-page rows="N">`)
12
+ - Headers and footers at every level
13
+ - Per-container statistics (`sum`, `avg`, `min`, `max`, `count`)
14
+ - Optional group-header reprint on page break
15
+
16
+ ## Installation
17
+
18
+ ```ruby
19
+ # Gemfile
20
+ gem "repolho"
21
+ ```
22
+
23
+ ```bash
24
+ bundle install
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ Plain Ruby:
30
+
31
+ ```ruby
32
+ require "repolho"
33
+
34
+ rows = Order.connection.exec_query(sql)
35
+ # or
36
+ # rows = Order.joins(...).select(...).order(...)
37
+
38
+ report = Repolho::Main.new("app/views/reports/sales.rep.erb", rows, { company: "Acme" })
39
+ report.run
40
+
41
+ html = report.content.join
42
+ ```
43
+
44
+ Or with Rails:
45
+
46
+ ```ruby
47
+ render html: html.html_safe
48
+ # or File.write("report.html", html)
49
+ ```
50
+
51
+ ## Template (`.rep.erb`)
52
+
53
+ Repolho uses a few custom elements inside a normal HTML document. Everything
54
+ outside `<report>…</report>` is copied as-is (the `previous` and `after`
55
+ sections). Everything inside the custom elements is processed as ERB.
56
+
57
+ ### Custom elements
58
+
59
+ | Element | Purpose | Attributes |
60
+ |---------|---------|------------|
61
+ | `<report>` | Root container of the report | — |
62
+ | `<report-page>` | Defines a page (required, only one) | `rows` (required): max data rows per page |
63
+ | `<report-group>` | Nested grouping | `id` (required), `attr` (required), `reprint-header-on-page-break` (optional, `"true"`) |
64
+ | `<report-header>` | Header of the current container | — |
65
+ | `<report-footer>` | Footer of the current container | — |
66
+ | `<report-row>` | Template for each data row (inside the innermost group) | — |
67
+
68
+ ### Allowed HTML inside page content
69
+
70
+ Each page is emitted as a `<tbody class="repolho-page" data-page="N">`.
71
+ Because of HTML table rules, **only `<tr>` elements** may appear as direct
72
+ children of that `<tbody>`.
73
+
74
+ | Allowed | Not allowed | Why |
75
+ |---------|-------------|-----|
76
+ | `<tr>` (with `<td>` / `<th>`) | `<thead>` | `<thead>` cannot be a child of `<tbody>`. The browser moves it out and empties the page container. |
77
+ | | `<tfoot>` | Same reason. |
78
+ | | `<div>`, `<p>`, `<section>`, etc. | Invalid inside a table. |
79
+
80
+ Use `<tr>` for headers and footers of pages and groups. Reserve `<thead>` /
81
+ `<tfoot>` only for content that lives **outside** the page bodies (for example
82
+ a report-level header that should appear before any page, if you place it
83
+ before the first `<report-page>`).
84
+
85
+ ### Page wrapper
86
+
87
+ Repolho automatically wraps each page like this:
88
+
89
+ ```html
90
+ <tbody class="repolho-page" data-page="1" style="break-after: page; page-break-after: always;">
91
+ <!-- only <tr> elements here -->
92
+ </tbody>
93
+ ```
94
+
95
+ The `data-page` attribute and the `break-after` style are injected for you.
96
+ You do not need to write the `<tbody>` yourself.
97
+
98
+ ### Template example
99
+
100
+ ```erb
101
+ <!DOCTYPE html>
102
+ <html>
103
+ <head>
104
+ <title>Sales report</title>
105
+ </head>
106
+ <body>
107
+ <table>
108
+ <report>
109
+ <report-header>
110
+ <tr><th colspan="100"><%= @company %></th></tr>
111
+ <tr><th colspan="100">Sales report</th></tr>
112
+ </report-header>
113
+
114
+ <report-page rows="40">
115
+ <report-header>
116
+ <tr>
117
+ <td>Id</td>
118
+ <td>Name</td>
119
+ <td class="text-end">Value</td>
120
+ </tr>
121
+ </report-header>
122
+
123
+ <report-footer>
124
+ <tr>
125
+ <td colspan="2">Page total</td>
126
+ <td><%= format("%.2f", sum(:value)) %></td>
127
+ </tr>
128
+ </report-footer>
129
+
130
+ <report-group id="state" attr="state" reprint-header-on-page-break="true">
131
+ <report-header>
132
+ <tr>
133
+ <td>State:</td>
134
+ <td colspan="100"><%= attr(:state) %></td>
135
+ </tr>
136
+ </report-header>
137
+ <report-footer>
138
+ <tr>
139
+ <td colspan="2"><%= attr(:state) %> total</td>
140
+ <td>
141
+ <%= format("%.2f", sum(:value)) %>
142
+ (max: <%= format("%.2f", max(:value)) %>)
143
+ </td>
144
+ </tr>
145
+ </report-footer>
146
+
147
+ <report-row>
148
+ <tr>
149
+ <td><%= attr(:id) %></td>
150
+ <td><%= attr(:name) %></td>
151
+ <td class="text-end"><%= format("%.2f", attr(:value)) %></td>
152
+ </tr>
153
+ </report-row>
154
+ </report-group>
155
+ </report-page>
156
+
157
+ <report-footer>
158
+ <tr>
159
+ <td colspan="2">Grand total</td>
160
+ <td><%= format("%.2f", sum(:value)) %></td>
161
+ </tr>
162
+ </report-footer>
163
+ </report>
164
+ </table>
165
+ </body>
166
+ </html>
167
+ ```
168
+
169
+ To preview the structure, rename the file to `.html` and open it in a browser
170
+ (the custom tags will be ignored).
171
+
172
+ ## Methods available in ERB
173
+
174
+ | Method | Description |
175
+ |--------|-------------|
176
+ | `attr(:field)` | Value of the field on the current row |
177
+ | `sum(:field)` | Sum within the current container |
178
+ | `avg(:field)` | Average |
179
+ | `min(:field)` / `max(:field)` | Minimum / maximum |
180
+ | `count(:field)` | Number of values |
181
+ | `number` | Current page or group number |
182
+
183
+ ## Signing
184
+
185
+ This gem is cryptographically signed. To verify the signature on install:
186
+
187
+ ```bash
188
+ # Trust the public certificate (once)
189
+ gem cert --add <(curl -Ls https://raw.githubusercontent.com/taq/repolho/main/certs/taq.pem)
190
+
191
+ # Install with signature verification
192
+ gem install repolho -P HighSecurity
193
+ ```
194
+
195
+ ## License
196
+
197
+ MIT
data/certs/taq.pem ADDED
@@ -0,0 +1,27 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIEhTCCAu2gAwIBAgIBATANBgkqhkiG9w0BAQsFADBEMQwwCgYDVQQDDAN0YXEx
3
+ HzAdBgoJkiaJk/IsZAEZFg9ldXN0YXF1aW9yYW5nZWwxEzARBgoJkiaJk/IsZAEZ
4
+ FgNjb20wHhcNMjYxMDIxMTE0NzI1WhcNMjcxMDIxMTE0NzI1WjBEMQwwCgYDVQQD
5
+ DAN0YXExHzAdBgoJkiaJk/IsZAEZFg9ldXN0YXF1aW9yYW5nZWwxEzARBgoJkiaJ
6
+ k/IsZAEZFgNjb20wggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQC/IBvw
7
+ KRM7/ta27EE/3J4IMa2gU62Mr7TdpxE6tsakFL6sciN0P3iKUKl3t7nh7Hy24dFi
8
+ eRTojvesAwkLent90rltoVhYAScmPzsbm55phJNVAxGS1H43aAuOTiDskIscxmQ9
9
+ MIQsfP/UBJHfWnNXDykg3yCMkXV+ND7rJwG7NBxP5DL8BUFzxAvtdhi0Yp3BjXR6
10
+ 1s7RDfsyoVpIuC9Kv0bZwhGivWF1skTiuuOMSPYdMAVcsap3yaOb59d9QCNB51ov
11
+ Djdunz6jJG8dcTJ8RW1ZoOutTJ9CBtWofi1gfXZeJox7AhpwaPeHiEqD1gTaJd4j
12
+ wGEJiA18O/z00Qw24JwBj3N7EgABRUmlqRuFnq8+1pUX01H/MLKBWbeWj24R/z6C
13
+ Hz472zW9KuJuYu26h3gmcMzm/dVPdRfKo6QuQOwVI5nkIsioMbyA4aw4c0IB7PVU
14
+ uxKm4N2d5FjuG+D9MS4mImUGWt+qaSTlq32hW3vXOalj06JJ7dIbImHzcN0CAwEA
15
+ AaOBgTB/MAkGA1UdEwQCMAAwCwYDVR0PBAQDAgSwMB0GA1UdDgQWBBQjX2PXFvyu
16
+ yyFP4oaoAcl5TRwc1DAiBgNVHREEGzAZgRd0YXFAZXVzdGFxdWlvcmFuZ2VsLmNv
17
+ bTAiBgNVHRIEGzAZgRd0YXFAZXVzdGFxdWlvcmFuZ2VsLmNvbTANBgkqhkiG9w0B
18
+ AQsFAAOCAYEASYzNdAlSlxxO7RDEDELrjlohNVmWJYYGmhe6p2Zis247iBvRwl1V
19
+ Ry9t1Kvp5CTwZFG3aSUCnKdcJ3fCIJfKOus55XHlRD0iNhg9n01LM4ngpYM9yTgi
20
+ T+0lwq0Y8zBW7oBZKhyu+XkkSCDPSWVSyKWlbfHxuSZrP4ccN4geDS76graQRyq+
21
+ U12DiNk5jMYstTMy4C8F09g+h2qoWc6SoeenJNa+GzMFMTkHxtGDYfU5BwPjucJX
22
+ ZH2W0WW/YNLsfevFhi/uIZTZii2AYdAw33lDEADgLVA2s8qqn6TF888jVEPPWV6m
23
+ sUrk8mNBh3BfGv6BP7/mSZrxdriwFbeUxN8oot4zCitSa0nYBqsa97eGmABrPrWz
24
+ 5iRFVhCzMkHk+TMT2rAYX41OPS2Xa3YEJeMgmHJfnTIoEfIYQXtbkFqOMUJACISV
25
+ L0XcRn7yJijWxXFMBrnwp/4PHjPT0l6Qci+zxGly0D7LCTHa5WSyWujq4BZw55Yp
26
+ G13dwkTphaMB
27
+ -----END CERTIFICATE-----
@@ -0,0 +1,184 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+ require "erb"
4
+ require "cgi"
5
+ require_relative "stats"
6
+
7
+ module Repolho
8
+ class Container
9
+ attr_accessor :header, :footer, :containers, :row_template, :attr, :page, :status, :reprint_header_on_page_break
10
+ attr_reader :id, :number
11
+
12
+ UNSTARTED = 0
13
+ STARTED = 1
14
+ FINISHED = 2
15
+
16
+ def initialize(id, locals, stats)
17
+ @id = id
18
+ @locals = locals
19
+ @stats = stats
20
+ @header = nil
21
+ @footer = nil
22
+ @containers = []
23
+ @status = UNSTARTED
24
+ @row_template = nil
25
+ @cur_row = nil
26
+ @prev_row = nil
27
+ @number = 1
28
+ @attr = nil
29
+ @page = nil
30
+
31
+ create_locals
32
+ end
33
+
34
+ def call(type, data)
35
+ @prev_row = @cur_row
36
+
37
+ check_attribute_break(data)
38
+ check_page_break
39
+
40
+ @cur_row = data if data
41
+ update_stats if data && type == :row
42
+ check_started
43
+
44
+ @containers.each do |container|
45
+ container.call(type, data)
46
+ end
47
+
48
+ @page << row if type == :row
49
+ check_finish(type)
50
+ end
51
+
52
+ def start
53
+ return unless @header
54
+
55
+ ERB.new(CGI.unescapeHTML(@header[:content])).result(binding)
56
+ end
57
+
58
+ def finish
59
+ return unless @footer
60
+
61
+ rst = ERB.new(CGI.unescapeHTML(@footer[:content])).result(binding)
62
+
63
+ @number += 1
64
+ rst
65
+ end
66
+
67
+ def row
68
+ ERB.new(CGI.unescapeHTML(@row_template)).result(binding) if @row_template
69
+ end
70
+
71
+ def row_number = @page.row_number
72
+
73
+ def sum(attr)
74
+ stat(attr)&.sum || 0.0
75
+ end
76
+
77
+ def avg(attr)
78
+ stat(attr)&.avg || 0.0
79
+ end
80
+
81
+ def count(attr)
82
+ stat(attr)&.count || 0
83
+ end
84
+
85
+ def min(attr)
86
+ stat(attr)&.min
87
+ end
88
+
89
+ def max(attr)
90
+ stat(attr)&.max
91
+ end
92
+
93
+ def attr(attr)
94
+ return "" unless @cur_row
95
+
96
+ @cur_row[attr.to_s]
97
+ end
98
+
99
+ def reset_stats
100
+ @stats.each_value(&:reset)
101
+ end
102
+
103
+ def force_header_reprint
104
+ @status = FINISHED if @status == STARTED
105
+ end
106
+
107
+ def force_header_reprint_on_children
108
+ @containers.each do |container|
109
+ container.force_header_reprint if container.reprint_header_on_page_break
110
+ container.force_header_reprint_on_children
111
+ end
112
+ end
113
+
114
+ private
115
+
116
+ def create_locals
117
+ @locals.each do |name, value|
118
+ instance_variable_set("@#{name}".to_sym, value)
119
+ end
120
+ end
121
+
122
+ def update_stats
123
+ return unless @cur_row
124
+
125
+ @stats.each do |key, stats|
126
+ stats.add(@cur_row[key])
127
+ end
128
+ end
129
+
130
+ def stat(attr)
131
+ @stats[attr.to_s]
132
+ end
133
+
134
+ def check_attribute_break(data)
135
+ return unless @attr && @prev_row && data
136
+ return if self.is_a?(Page) || @status == FINISHED
137
+
138
+ prev_attr = @prev_row[@attr]
139
+ cur_attr = data[@attr]
140
+
141
+ if prev_attr != cur_attr
142
+ @containers.each do |container|
143
+ next if container.status == FINISHED
144
+
145
+ @page << container.finish
146
+ container.reset_stats
147
+ container.status = FINISHED
148
+ end
149
+
150
+ @page << finish
151
+ reset_stats
152
+ @status = FINISHED
153
+ end
154
+ end
155
+
156
+ def check_page_break
157
+ return unless self.is_a?(Page)
158
+ return unless @row_number >= @valid_rows
159
+
160
+ @page << finish
161
+ reset_stats
162
+ @row_number = 0
163
+ @status = FINISHED
164
+
165
+ # Force header reprint on groups that requested it
166
+ force_header_reprint_on_children
167
+ end
168
+
169
+ def check_started
170
+ return unless @status != STARTED
171
+
172
+ @page << start
173
+ @status = STARTED
174
+ end
175
+
176
+ def check_finish(type)
177
+ return unless type == :finish && @status != FINISHED
178
+
179
+ @page << finish
180
+ @status = FINISHED
181
+ reset_stats
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,158 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+ require_relative "container"
4
+ require_relative "page"
5
+ require_relative "report"
6
+ require_relative "stats"
7
+ require "nokogiri"
8
+
9
+ module Repolho
10
+ class Main
11
+ def initialize(path, data, locals = {})
12
+ @path = path
13
+ @data = normalize_data(data)
14
+ @locals = locals
15
+ @columns = make_columns
16
+ @page = Page.new("page", locals, make_stats)
17
+ @report = Report.new("report", locals, make_stats)
18
+ @previous = ""
19
+ @after = ""
20
+ @body = ""
21
+
22
+ @report.containers << @page
23
+ @report.page = @page
24
+ end
25
+
26
+ def content
27
+ @page.content
28
+ end
29
+
30
+ def run
31
+ parse
32
+
33
+ @page.append_raw(@previous)
34
+ @page.append_raw(@body)
35
+
36
+ @data.each do |row|
37
+ @report.call(:row, row)
38
+ end
39
+
40
+ @report.call(:finish, nil)
41
+ @page.append_raw(@after)
42
+ @page.finalize
43
+ end
44
+
45
+ private
46
+
47
+ def make_columns
48
+ return [] if @data.empty?
49
+
50
+ sample = @data.first
51
+
52
+ sample.keys.select do |col|
53
+ val = sample[col]
54
+ val.is_a?(Numeric) || (val.is_a?(String) && val.match?(/\A-?\d+(\.\d+)?\z/))
55
+ end
56
+ end
57
+
58
+ def normalize_data(data)
59
+ return [] if data.nil? || (data.respond_to?(:empty?) && data.empty?)
60
+
61
+ # ActiveRecord::Result (exec_query)
62
+ if data.respond_to?(:columns) && data.respond_to?(:each)
63
+ return data.map { |row| row.to_h.transform_keys(&:to_s) }
64
+ end
65
+
66
+ # ActiveRecord::Relation, array of models, or any Enumerable of objects with attributes
67
+ rows = data.respond_to?(:to_a) ? data.to_a : Array(data)
68
+ return [] if rows.empty?
69
+
70
+ first = rows.first
71
+ if first.respond_to?(:attributes)
72
+ return rows.map { |r| r.attributes.transform_keys(&:to_s) }
73
+ end
74
+
75
+ # Already an array of hashes
76
+ if first.is_a?(Hash)
77
+ return rows.map { |r| r.transform_keys(&:to_s) }
78
+ end
79
+
80
+ rows
81
+ end
82
+
83
+ def make_stats
84
+ @columns.each_with_object({}) do |column, memo|
85
+ memo[column] = Stats.new
86
+ end
87
+ end
88
+
89
+ def parse
90
+ content = File.read(@path)
91
+ extract_sections(content)
92
+ build_report_structure
93
+ end
94
+
95
+ def extract_sections(content)
96
+ matches = content.gsub(/\n/, "").match(/(?<previous>\A.*?)(?<body><body[^>]*>.*?)(?<report><report>.*?<\/report>)(?<after>.*)\z/m)
97
+
98
+ @body = matches[:body]
99
+ @previous = matches[:previous]
100
+ @after = matches[:after]
101
+ @report_xml = matches[:report]
102
+ end
103
+
104
+ def build_report_structure
105
+ parsed = Nokogiri::HTML.parse(@report_xml)
106
+ report = parsed.search("report")
107
+
108
+ @report.header = info(report.search("> report-header").first)
109
+ @report.footer = info(report.search("> report-footer").first)
110
+
111
+ build_page(report)
112
+ build_groups(report)
113
+ end
114
+
115
+ def build_page(report)
116
+ page = report.search("report-page").first
117
+
118
+ @page.header = info(page.search("> report-header").first)
119
+ @page.footer = info(page.search("> report-footer").first)
120
+ @page.row_template = report.search("report-row").first&.inner_html
121
+ @page.rows = page["rows"].to_i if page["rows"]
122
+ @page.page = @page
123
+ end
124
+
125
+ def build_groups(report)
126
+ page = report.search("report-page").first
127
+ ref_container = @page
128
+ ref_element = page
129
+
130
+ while ref_element
131
+ next_container = ref_element.search("> report-group").first
132
+ break unless next_container
133
+
134
+ container = build_group(next_container)
135
+ ref_container.containers << container
136
+
137
+ ref_container = container
138
+ ref_element = next_container
139
+ end
140
+ end
141
+
142
+ def build_group(element)
143
+ container = Container.new(element["id"], @locals, make_stats)
144
+ container.header = info(element.search("> report-header").first)
145
+ container.footer = info(element.search("> report-footer").first)
146
+ container.attr = element["attr"]
147
+ container.reprint_header_on_page_break = element["reprint-header-on-page-break"] == "true"
148
+ container.page = @page
149
+ container
150
+ end
151
+
152
+ def info(element)
153
+ return { content: "" } unless element
154
+
155
+ { content: element.inner_html }
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,77 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Repolho
5
+ class Page < Container
6
+ attr_reader :content, :row_number, :rows, :page_number
7
+
8
+ def initialize(id, locals, stats)
9
+ super(id, locals, stats)
10
+
11
+ @content = []
12
+ @rows = 60
13
+ @valid_rows = @rows
14
+ @row_number = 1
15
+ @page_number = 1
16
+ @page_open = false
17
+ end
18
+
19
+ def rows=(number)
20
+ @rows = number
21
+ @valid_rows = @rows
22
+ end
23
+
24
+ def <<(content)
25
+ return unless content
26
+
27
+ open_page unless @page_open
28
+
29
+ content = content.is_a?(String) ? content : Array(content).join
30
+
31
+ parsed = Nokogiri::HTML(content)
32
+ trs = parsed.search("tr")
33
+ @row_number += trs.size
34
+ @content << content
35
+ end
36
+
37
+ # Appends content outside the page tbodies (previous, body, after)
38
+ def append_raw(content)
39
+ return unless content
40
+
41
+ @content << content
42
+ end
43
+
44
+ def open_page
45
+ return if @page_open
46
+
47
+ @content << %(<tbody class="repolho-page" data-page="#{@page_number}" style="break-after: page; page-break-after: always;">)
48
+ @page_open = true
49
+ end
50
+
51
+ def close_page
52
+ return unless @page_open
53
+
54
+ @content << "</tbody>"
55
+ @page_open = false
56
+ @page_number += 1
57
+ end
58
+
59
+ def finalize
60
+ close_page
61
+ end
62
+
63
+ private
64
+
65
+ def check_page_break
66
+ return unless @row_number >= @valid_rows
67
+
68
+ @page << finish
69
+ close_page
70
+ reset_stats
71
+ @row_number = 0
72
+ @status = FINISHED
73
+
74
+ force_header_reprint_on_children
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,7 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Repolho
5
+ class Report < Container
6
+ end
7
+ end
@@ -0,0 +1,33 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Repolho
5
+ class Stats
6
+ attr_reader :sum, :count, :min, :max
7
+
8
+ def initialize
9
+ reset
10
+ end
11
+
12
+ def add(value)
13
+ return if value.nil?
14
+
15
+ numeric = value.is_a?(Numeric) ? value : value.to_f
16
+ @sum += numeric
17
+ @count += 1
18
+ @min = numeric if @min.nil? || numeric < @min
19
+ @max = numeric if @max.nil? || numeric > @max
20
+ end
21
+
22
+ def avg
23
+ @count.zero? ? 0.0 : @sum / @count
24
+ end
25
+
26
+ def reset
27
+ @sum = 0.0
28
+ @count = 0
29
+ @min = nil
30
+ @max = nil
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Repolho
4
+ VERSION = "0.1.0"
5
+ end
data/lib/repolho.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "repolho/version"
4
+ require_relative "repolho/stats"
5
+ require_relative "repolho/container"
6
+ require_relative "repolho/page"
7
+ require_relative "repolho/report"
8
+ require_relative "repolho/main"
9
+
10
+ module Repolho
11
+ end
data.tar.gz.sig ADDED
@@ -0,0 +1,3 @@
1
+ |��0$��^���}q���'�,��lR>'�.�>�D]�� o���DY�m���} ���*�ӛ'c�a��z/Q����Y~������z(߀��7 ߸U�[!6%�
2
+ pzm�6f�6��/W!
3
+ L�4
metadata ADDED
@@ -0,0 +1,99 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: repolho
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Eustáquio Rangel
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain:
11
+ - |
12
+ -----BEGIN CERTIFICATE-----
13
+ MIIEhTCCAu2gAwIBAgIBATANBgkqhkiG9w0BAQsFADBEMQwwCgYDVQQDDAN0YXEx
14
+ HzAdBgoJkiaJk/IsZAEZFg9ldXN0YXF1aW9yYW5nZWwxEzARBgoJkiaJk/IsZAEZ
15
+ FgNjb20wHhcNMjYxMDIxMTE0NzI1WhcNMjcxMDIxMTE0NzI1WjBEMQwwCgYDVQQD
16
+ DAN0YXExHzAdBgoJkiaJk/IsZAEZFg9ldXN0YXF1aW9yYW5nZWwxEzARBgoJkiaJ
17
+ k/IsZAEZFgNjb20wggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQC/IBvw
18
+ KRM7/ta27EE/3J4IMa2gU62Mr7TdpxE6tsakFL6sciN0P3iKUKl3t7nh7Hy24dFi
19
+ eRTojvesAwkLent90rltoVhYAScmPzsbm55phJNVAxGS1H43aAuOTiDskIscxmQ9
20
+ MIQsfP/UBJHfWnNXDykg3yCMkXV+ND7rJwG7NBxP5DL8BUFzxAvtdhi0Yp3BjXR6
21
+ 1s7RDfsyoVpIuC9Kv0bZwhGivWF1skTiuuOMSPYdMAVcsap3yaOb59d9QCNB51ov
22
+ Djdunz6jJG8dcTJ8RW1ZoOutTJ9CBtWofi1gfXZeJox7AhpwaPeHiEqD1gTaJd4j
23
+ wGEJiA18O/z00Qw24JwBj3N7EgABRUmlqRuFnq8+1pUX01H/MLKBWbeWj24R/z6C
24
+ Hz472zW9KuJuYu26h3gmcMzm/dVPdRfKo6QuQOwVI5nkIsioMbyA4aw4c0IB7PVU
25
+ uxKm4N2d5FjuG+D9MS4mImUGWt+qaSTlq32hW3vXOalj06JJ7dIbImHzcN0CAwEA
26
+ AaOBgTB/MAkGA1UdEwQCMAAwCwYDVR0PBAQDAgSwMB0GA1UdDgQWBBQjX2PXFvyu
27
+ yyFP4oaoAcl5TRwc1DAiBgNVHREEGzAZgRd0YXFAZXVzdGFxdWlvcmFuZ2VsLmNv
28
+ bTAiBgNVHRIEGzAZgRd0YXFAZXVzdGFxdWlvcmFuZ2VsLmNvbTANBgkqhkiG9w0B
29
+ AQsFAAOCAYEASYzNdAlSlxxO7RDEDELrjlohNVmWJYYGmhe6p2Zis247iBvRwl1V
30
+ Ry9t1Kvp5CTwZFG3aSUCnKdcJ3fCIJfKOus55XHlRD0iNhg9n01LM4ngpYM9yTgi
31
+ T+0lwq0Y8zBW7oBZKhyu+XkkSCDPSWVSyKWlbfHxuSZrP4ccN4geDS76graQRyq+
32
+ U12DiNk5jMYstTMy4C8F09g+h2qoWc6SoeenJNa+GzMFMTkHxtGDYfU5BwPjucJX
33
+ ZH2W0WW/YNLsfevFhi/uIZTZii2AYdAw33lDEADgLVA2s8qqn6TF888jVEPPWV6m
34
+ sUrk8mNBh3BfGv6BP7/mSZrxdriwFbeUxN8oot4zCitSa0nYBqsa97eGmABrPrWz
35
+ 5iRFVhCzMkHk+TMT2rAYX41OPS2Xa3YEJeMgmHJfnTIoEfIYQXtbkFqOMUJACISV
36
+ L0XcRn7yJijWxXFMBrnwp/4PHjPT0l6Qci+zxGly0D7LCTHa5WSyWujq4BZw55Yp
37
+ G13dwkTphaMB
38
+ -----END CERTIFICATE-----
39
+ date: 2026-10-21 00:00:00.000000000 Z
40
+ dependencies:
41
+ - !ruby/object:Gem::Dependency
42
+ name: nokogiri
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.16'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.16'
55
+ description: Repolho generates HTML reports from .rep.erb templates with support for
56
+ nested groupings, page breaks, headers/footers, and statistics (sum, avg, min, max).
57
+ Works with ActiveRecord::Result, Relation, or arrays of hashes.
58
+ email:
59
+ - taq@eustaquiorangel.com
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - LICENSE
65
+ - README.md
66
+ - certs/taq.pem
67
+ - lib/repolho.rb
68
+ - lib/repolho/container.rb
69
+ - lib/repolho/main.rb
70
+ - lib/repolho/page.rb
71
+ - lib/repolho/report.rb
72
+ - lib/repolho/stats.rb
73
+ - lib/repolho/version.rb
74
+ homepage: https://github.com/taq/repolho
75
+ licenses:
76
+ - MIT
77
+ metadata:
78
+ homepage_uri: https://github.com/taq/repolho
79
+ source_code_uri: https://github.com/taq/repolho
80
+ post_install_message:
81
+ rdoc_options: []
82
+ require_paths:
83
+ - lib
84
+ required_ruby_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: 3.2.0
89
+ required_rubygems_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ requirements: []
95
+ rubygems_version: 3.5.3
96
+ signing_key:
97
+ specification_version: 4
98
+ summary: Report generator for Ruby and Rails from .rep.erb templates
99
+ test_files: []
metadata.gz.sig ADDED
@@ -0,0 +1,3 @@
1
+ ;�n���W;�Q1�B���i&�`=L6]X���ژ�������i����oU�U���~�D�.IvU���}/�lpW�NH/��Ù����>W �}�$��lya[Ae��1�%���`Ck&g��������D=�Bm��� .c�^�q%�Y9�Y���t��YQ����%2 �� G�w<��l3��f�.�ddޟ�9�:{���i�7n�i�c+��.�ę���d�O}�s�7��MD&�>���/�����M��r�t����h��
2
+ C7�v������m`4�d�_�}\~��J�b0K
3
+ JI{be!��XN�*lRBհ겓o�!f٘'�"k�\b�\��b$�(��Ǥp�