Skip to content

Commit d488811

Browse files
committed
Add plain ol' Ruby pipeline functionality
- raise errors using gem-based classes
1 parent d0f1d42 commit d488811

File tree

10 files changed

+223
-21
lines changed

10 files changed

+223
-21
lines changed

CHANGELOG.md

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Changelog
2+
3+
## 2.0.0
4+
5+
- Add plain ol' Ruby pipeline functionality
6+
- Raise errors using gem-based classes
7+
- Upgrade docs site to Bridgetown 1.3.1 and esbuild
8+
9+
## 1.0.1
10+
11+
- Widespread release.

docs/frontend/styles/index.scss

+16
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,22 @@ p, ul li, ol li {
7070
margin-bottom: 1.5rem;
7171
}
7272

73+
main > aside {
74+
background: #ffffcc;
75+
padding: 1.5rem;
76+
border: 1px solid #eee;
77+
box-shadow: 0px 5px 12px -4px #eee;
78+
border-radius: 8px;
79+
80+
*:first-child {
81+
margin-top: 0;
82+
}
83+
84+
*:last-child {
85+
margin-bottom: 0;
86+
}
87+
}
88+
7389
div.highlighter-rouge {
7490
margin: 1.5rem 0;
7591
width: 100%;

docs/server/roda_app.rb

+4-5
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@
33
# server, but you can also run it in production for fast, dynamic applications.
44
#
55
# Learn more at: http://roda.jeremyevans.net
6+
class RodaApp < Roda
7+
plugin :bridgetown_server
68

7-
class RodaApp < Bridgetown::Rack::Roda
8-
# Add Roda configuration here if needed
9-
10-
route do
9+
route do |r|
1110
# Load all the files in server/routes
1211
# see hello.rb.sample
13-
Bridgetown::Rack::Routes.start! self
12+
r.bridgetown
1413
end
1514
end

docs/src/index.md

+106-3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ layout: home
99

1010
**Serbea**. Finally, something to crow(n) about. _Le roi est mort, vive le roi!_
1111

12+
<aside markdown="block">
13+
14+
==New in Serbea 2.0!== You can now add "pipeline operator" functionality to _any_ Ruby template or class! [Check out the documentation below.](#add-pipelines-to-any-ruby-templates)
15+
16+
</aside>
17+
1218
### Table of Contents
1319
{:.no_toc}
1420
*
@@ -43,10 +49,11 @@ layout: home
4349
[10, 20, 30]
4450
```
4551

52+
* Alternatively, ==now in Serbea 2.0== you can execute pipelines in plain ol' Ruby within any template or class! [See documentation below.](#add-pipelines-to-any-ruby-templates)
4653
* Serbea will HTML autoescape variables by default within pipeline (`{{ }}`) tags. Use the `safe` / `raw` or `escape` / `h` filters to control escaping on output.
4754
* Directives apply handy shortcuts that modify the template at the syntax level before processing through Ruby.
4855

49-
`{%@ %}` is a shortcut for rendering either string-named partials (`render "tmpl"`) or object instances (`render MyComponent.new`). And in Rails, you can use Turbo Stream directives for extremely consise templates:
56+
`{%@ %}` is a shortcut for rendering either string-named partials (`render "tmpl"`) or object instances (`render MyComponent.new`). And in Rails, you can use Turbo Stream directives for extremely concise templates:
5057

5158
```serbea
5259
{%@remove "timeline-read-more" %}
@@ -222,7 +229,7 @@ Serbea is an excellent upgrade from Liquid as the syntax initially looks familar
222229

223230
Out of the box, you can name pages and partials with a `.serb` extension. But for even more flexibility, you can add `template_engine: serbea` to your `bridgetown.config.yml` configuration. This will default all pages and documents to Serbea unless you specifically use front matter to choose a different template engine (or use an extension such as `.liquid` or `.erb`).
224231

225-
Here's an abreviated example of what the Post layout template looks like on the [RUBY3.dev](https://www.ruby3.dev) blog:
232+
Here's an abreviated example of what the Post layout template looks like on the [Fullstack Ruby](https://www.fullstackruby.dev) blog:
226233

227234
{% raw %}
228235
```serb
@@ -286,6 +293,102 @@ which is _far_ easier to parse visually and less likely to cause bugs due to nes
286293

287294
{% endraw %}
288295

296+
### Add Pipelines to Any Ruby Templates
297+
298+
New in Serbea 2.0, you can use a pipeline operator (`|`) within a `pipe` block to construct a series of expressions which continually operate on the latest state of the base value.
299+
300+
All you have to do is include `Serbea::Pipeline::Helper` inside of any Ruby class or template environment (aka ERB).
301+
302+
Here's a simple example:
303+
304+
```ruby
305+
class PipelineExample
306+
include Serbea::Pipeline::Helper
307+
308+
def output
309+
pipe("Hello world") { upcase | split(" ") | test_join(", ") }
310+
end
311+
312+
def test_join(input, delimeter)
313+
input.join(delimeter)
314+
end
315+
end
316+
317+
PipelineExample.new.output.value # => HELLO, WORLD
318+
```
319+
320+
As you can see, a number of interesting things are happening here. First, we're kicking off the pipeline using a string value. This then lets us access the string's `upcase` and `split` methods. Once the string has become an array, we pipe that into our custom `test_join` method where we can call the array value's `join` method to convert it back to a string. Finally, we return the output value of the pipeline.
321+
322+
Like in native Serbea template pipelines, every expression in the pipeline will either call a method on the value itself, or a filter-style method that's available within the calling object. As you might expect in, say, an ERB template, all of the helpers are available as pipeline filters. In Rails, for example:
323+
324+
```erb
325+
Link: <%= pipe("nav.page_link") { t | link_to(my_page_path) } %>
326+
```
327+
328+
This is roughly equivalent to:
329+
330+
```erb
331+
Link: <%= link_to(t("nav.page_link"), my_page_path) %>
332+
```
333+
334+
The length of the pipe code is slightly longer, but it's easier to follow the order of operations:
335+
336+
1. First, you start with the translation key.
337+
2. Second, you translate that into official content.
338+
3. Third, you pass that content to `link_to` along with a URL helper.
339+
340+
There are all sorts of uses for a pipeline, not just in templates. You could construct an entire data flow with many transformation steps. And because the pipeline operator `|` is actually optional when using a multi-line block, you can just write a series of simple Ruby statements:
341+
342+
```ruby
343+
def transform(input_value)
344+
pipe input_value do
345+
transform_this_way
346+
transform_that_way
347+
add_more_data(more_data)
348+
convert_to_whatever # maybe this is called on the value object itself
349+
value ->{ AnotherClass.operate_on_value _1 } # return a new value from outside processing
350+
now_we_are_done!
351+
end
352+
end
353+
354+
def transform_this_way(input) = ...
355+
def transform_that_way(input) = ...
356+
def add_more_data(input, data) = ...
357+
def now_we_are_done!(input) = ...
358+
359+
transform([1,2,3])
360+
```
361+
289362
### How Pipelines Work Under the Hood
290363
291-
Documentation forthcoming!
364+
In Serbea templates, code which looks like this:
365+
366+
{% raw %}
367+
```serb
368+
{{ data | method_call | some_filter: 123 }}
369+
```
370+
{% endraw %}
371+
372+
gets translated to this:
373+
374+
```ruby
375+
pipeline(data).filter(:method_call).filter(:some_filter, 123)
376+
```
377+
378+
In plain Ruby, `method_missing` is used to proxy method calls along to `filter`, so:
379+
380+
```ruby
381+
pipe(data) { method_call | some_filter(123) }
382+
```
383+
384+
is equivalent to:
385+
386+
```ruby
387+
pipe(data) { filter(:method_call); filter(:some_filter, 123) }
388+
```
389+
390+
Pipelines "inherit" their calling context by using Ruby's `binding` feature. That's how they know how to call the methods which are available within the caller.
391+
392+
Another interesting facet of Serbea pipelines is that they're forgiving by default. If a filter can't be found (either there's no method available to call the object itself nor is there a separate helper method), it will log a warning to STDERR and continue on. This is to make the syntax feel a bit more like HTML and CSS where you can make a mistake or encounter an unexpected error condition yet not crash the entire application.
393+
394+
If you do want to crash your entire application (😜), you can set the configuration option: `Serbea::Pipeline.raise_on_missing_filters = true`. This will raise a `Serbea::FilterMissing` error if a filter can't be found.

lib/serbea.rb

+6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
require "tilt"
22
require "tilt/erubi"
33

4+
module Serbea
5+
class Error < StandardError; end
6+
7+
class FilterMissing < Error; end
8+
end
9+
410
require "serbea/helpers"
511
require "serbea/pipeline"
612
require "serbea/template_engine"

lib/serbea/helpers.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def helper(name, &helper_block)
3030
def import(*args, **kwargs, &block)
3131
helper_names = %i(partial render)
3232
available_helper = helper_names.find { |meth| respond_to?(meth) }
33-
raise "Serbea Error: no `render' or `partial' helper available in #{self.class}" unless available_helper
33+
raise Serbea::Error, "Serbea Error: no `render' or `partial' helper available in #{self.class}" unless available_helper
3434
available_helper == :partial ? partial(*args, **kwargs, &block) : render(*args, **kwargs, &block)
3535
nil
3636
end

lib/serbea/pipeline.rb

+34-4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@
44

55
module Serbea
66
class Pipeline
7+
# If you include this in any regular Ruby template environment (say ERB),
8+
# you can then use Serbea-style pipeline code within the block, e.g.
9+
#
10+
# `pipe "Hello world" do upcase | split(" ") | join(", ") end`
11+
# => `HELLO, WORLD`
12+
module Helper
13+
def pipe(input = nil, &blk)
14+
Pipeline.new(binding, input).tap { _1.instance_exec(&blk) }.value
15+
end
16+
end
17+
718
# Exec the pipes!
819
# @param template [String]
920
# @param locals [Hash]
@@ -85,13 +96,13 @@ def filter(name, *args, **kwargs)
8596
if var.respond_to?(:call)
8697
@value = var.call(@value, *args, **kwargs)
8798
else
88-
"Serbea warning: Filter #{name} does not respond to call".tap do |warning|
89-
self.class.raise_on_missing_filters ? raise(warning) : STDERR.puts(warning)
99+
"Serbea warning: Filter '#{name}' does not respond to call".tap do |warning|
100+
self.class.raise_on_missing_filters ? raise(Serbea::FilterMissing, warning) : STDERR.puts(warning)
90101
end
91102
end
92103
else
93-
"Serbea warning: Filter not found: #{name}".tap do |warning|
94-
self.class.raise_on_missing_filters ? raise(warning) : STDERR.puts(warning)
104+
"Serbea warning: Filter `#{name}' not found".tap do |warning|
105+
self.class.raise_on_missing_filters ? raise(Serbea::FilterMissing, warning) : STDERR.puts(warning)
95106
end
96107
end
97108

@@ -101,5 +112,24 @@ def filter(name, *args, **kwargs)
101112
def to_s
102113
self.class.output_processor.call(@value.is_a?(String) ? @value : @value.to_s)
103114
end
115+
116+
def |(*)
117+
self
118+
end
119+
120+
def method_missing(...)
121+
filter(...)
122+
end
123+
124+
def value(callback = nil)
125+
return @value unless callback
126+
127+
@value = if callback.is_a?(Proc)
128+
callback.(@value)
129+
else
130+
callback
131+
end
132+
self
133+
end
104134
end
105135
end

lib/serbea/template_engine.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ def process_serbea_input(template, properties)
188188
buff << "{% %}\n" # preserve original directive line length
189189
end
190190
else
191-
raise "Handler for Serbea template directive `#{$1}' not found"
191+
raise Serbea::Error, "Handler for Serbea template directive `#{$1}' not found"
192192
end
193193
else
194194
buff << "{% end %}"

lib/version.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module Serbea
2-
VERSION = "1.0.1"
2+
VERSION = "2.0.0"
33
end

test/test.rb

+43-6
Original file line numberDiff line numberDiff line change
@@ -167,10 +167,6 @@ def turbo_stream
167167
end
168168
end
169169

170-
171-
#simple_template = "Hi {{ 'there' }}"
172-
#tmpl = Tilt::SerbeaTemplate.new { simple_template }
173-
174170
Serbea::TemplateEngine.front_matter_preamble = "self.pagedata = YAML.load"
175171
Serbea::TemplateEngine.directive :form, ->(code, buffer) do
176172
model_name, space, params = code.lstrip.partition(%r(\s)m)
@@ -199,7 +195,6 @@ def turbo_stream
199195
buffer << code
200196
buffer << " %}"
201197
end
202-
#Serbea::Pipeline.raise_on_missing_filters = true
203198

204199
tmpl = Tilt.new(File.join(__dir__, "template.serb"))
205200

@@ -230,4 +225,46 @@ def scope(name, func)
230225
raise "Output does not match! Saved to bad_output.txt"
231226
end
232227

233-
puts "\nYay! Test passed."
228+
class AnotherClass
229+
def self.operate_on_value(value)
230+
"val #{value} !!"
231+
end
232+
end
233+
234+
class PipelineTemplateTest
235+
include Serbea::Pipeline::Helper
236+
237+
def output
238+
pipe("Hello world") { upcase | split(" ") | value(->{ _1 | ["YO"] }) | test_join(", ") }
239+
end
240+
241+
def test_multiline(input_value)
242+
pipe input_value do
243+
transform_this_way
244+
value ->{ AnotherClass.operate_on_value _1 } # return a new value based on an outside process
245+
now_we_are_done!
246+
end
247+
end
248+
249+
def transform_this_way(input)
250+
input.join("=")
251+
end
252+
253+
def now_we_are_done!(input)
254+
input.upcase
255+
end
256+
257+
def test_join(input, delimeter)
258+
input.join(delimeter)
259+
end
260+
end
261+
262+
pipeline_output = PipelineTemplateTest.new.output
263+
raise "Pipeline broken! #{pipeline_output}" unless
264+
pipeline_output == "HELLO, WORLD, YO"
265+
266+
pipeline_output = PipelineTemplateTest.new.test_multiline(["a", 123])
267+
raise "Multi-line pipeline broken! #{pipeline_output}" unless
268+
pipeline_output == "VAL A=123 !!"
269+
270+
puts "\nYay! Tests passed."

0 commit comments

Comments
 (0)