Skip to content

Commit

Permalink
Add plain ol' Ruby pipeline functionality
Browse files Browse the repository at this point in the history
- raise errors using gem-based classes
  • Loading branch information
jaredcwhite committed Sep 21, 2023
1 parent d0f1d42 commit d488811
Show file tree
Hide file tree
Showing 10 changed files with 223 additions and 21 deletions.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Changelog

## 2.0.0

- Add plain ol' Ruby pipeline functionality
- Raise errors using gem-based classes
- Upgrade docs site to Bridgetown 1.3.1 and esbuild

## 1.0.1

- Widespread release.
16 changes: 16 additions & 0 deletions docs/frontend/styles/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,22 @@ p, ul li, ol li {
margin-bottom: 1.5rem;
}

main > aside {
background: #ffffcc;
padding: 1.5rem;
border: 1px solid #eee;
box-shadow: 0px 5px 12px -4px #eee;
border-radius: 8px;

*:first-child {
margin-top: 0;
}

*:last-child {
margin-bottom: 0;
}
}

div.highlighter-rouge {
margin: 1.5rem 0;
width: 100%;
Expand Down
9 changes: 4 additions & 5 deletions docs/server/roda_app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@
# server, but you can also run it in production for fast, dynamic applications.
#
# Learn more at: http://roda.jeremyevans.net
class RodaApp < Roda
plugin :bridgetown_server

class RodaApp < Bridgetown::Rack::Roda
# Add Roda configuration here if needed

route do
route do |r|
# Load all the files in server/routes
# see hello.rb.sample
Bridgetown::Rack::Routes.start! self
r.bridgetown
end
end
109 changes: 106 additions & 3 deletions docs/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ layout: home

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

<aside markdown="block">

==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)

</aside>

### Table of Contents
{:.no_toc}
*
Expand Down Expand Up @@ -43,10 +49,11 @@ layout: home
[10, 20, 30]
```

* 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)
* Serbea will HTML autoescape variables by default within pipeline (`{{ }}`) tags. Use the `safe` / `raw` or `escape` / `h` filters to control escaping on output.
* Directives apply handy shortcuts that modify the template at the syntax level before processing through Ruby.

`{%@ %}` 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:
`{%@ %}` 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:

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

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`).

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

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

{% endraw %}

### Add Pipelines to Any Ruby Templates

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.

All you have to do is include `Serbea::Pipeline::Helper` inside of any Ruby class or template environment (aka ERB).

Here's a simple example:

```ruby
class PipelineExample
include Serbea::Pipeline::Helper

def output
pipe("Hello world") { upcase | split(" ") | test_join(", ") }
end

def test_join(input, delimeter)
input.join(delimeter)
end
end

PipelineExample.new.output.value # => HELLO, WORLD
```

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.

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:

```erb
Link: <%= pipe("nav.page_link") { t | link_to(my_page_path) } %>
```

This is roughly equivalent to:

```erb
Link: <%= link_to(t("nav.page_link"), my_page_path) %>
```

The length of the pipe code is slightly longer, but it's easier to follow the order of operations:

1. First, you start with the translation key.
2. Second, you translate that into official content.
3. Third, you pass that content to `link_to` along with a URL helper.

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:

```ruby
def transform(input_value)
pipe input_value do
transform_this_way
transform_that_way
add_more_data(more_data)
convert_to_whatever # maybe this is called on the value object itself
value ->{ AnotherClass.operate_on_value _1 } # return a new value from outside processing
now_we_are_done!
end
end

def transform_this_way(input) = ...
def transform_that_way(input) = ...
def add_more_data(input, data) = ...
def now_we_are_done!(input) = ...

transform([1,2,3])
```
### How Pipelines Work Under the Hood
Documentation forthcoming!
In Serbea templates, code which looks like this:
{% raw %}
```serb
{{ data | method_call | some_filter: 123 }}
```
{% endraw %}
gets translated to this:
```ruby
pipeline(data).filter(:method_call).filter(:some_filter, 123)
```
In plain Ruby, `method_missing` is used to proxy method calls along to `filter`, so:
```ruby
pipe(data) { method_call | some_filter(123) }
```
is equivalent to:
```ruby
pipe(data) { filter(:method_call); filter(:some_filter, 123) }
```
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.
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.
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.
6 changes: 6 additions & 0 deletions lib/serbea.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
require "tilt"
require "tilt/erubi"

module Serbea
class Error < StandardError; end

class FilterMissing < Error; end
end

require "serbea/helpers"
require "serbea/pipeline"
require "serbea/template_engine"
Expand Down
2 changes: 1 addition & 1 deletion lib/serbea/helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def helper(name, &helper_block)
def import(*args, **kwargs, &block)
helper_names = %i(partial render)
available_helper = helper_names.find { |meth| respond_to?(meth) }
raise "Serbea Error: no `render' or `partial' helper available in #{self.class}" unless available_helper
raise Serbea::Error, "Serbea Error: no `render' or `partial' helper available in #{self.class}" unless available_helper
available_helper == :partial ? partial(*args, **kwargs, &block) : render(*args, **kwargs, &block)
nil
end
Expand Down
38 changes: 34 additions & 4 deletions lib/serbea/pipeline.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@

module Serbea
class Pipeline
# If you include this in any regular Ruby template environment (say ERB),
# you can then use Serbea-style pipeline code within the block, e.g.
#
# `pipe "Hello world" do upcase | split(" ") | join(", ") end`
# => `HELLO, WORLD`
module Helper
def pipe(input = nil, &blk)
Pipeline.new(binding, input).tap { _1.instance_exec(&blk) }.value
end
end

# Exec the pipes!
# @param template [String]
# @param locals [Hash]
Expand Down Expand Up @@ -85,13 +96,13 @@ def filter(name, *args, **kwargs)
if var.respond_to?(:call)
@value = var.call(@value, *args, **kwargs)
else
"Serbea warning: Filter #{name} does not respond to call".tap do |warning|
self.class.raise_on_missing_filters ? raise(warning) : STDERR.puts(warning)
"Serbea warning: Filter '#{name}' does not respond to call".tap do |warning|
self.class.raise_on_missing_filters ? raise(Serbea::FilterMissing, warning) : STDERR.puts(warning)
end
end
else
"Serbea warning: Filter not found: #{name}".tap do |warning|
self.class.raise_on_missing_filters ? raise(warning) : STDERR.puts(warning)
"Serbea warning: Filter `#{name}' not found".tap do |warning|
self.class.raise_on_missing_filters ? raise(Serbea::FilterMissing, warning) : STDERR.puts(warning)
end
end

Expand All @@ -101,5 +112,24 @@ def filter(name, *args, **kwargs)
def to_s
self.class.output_processor.call(@value.is_a?(String) ? @value : @value.to_s)
end

def |(*)
self
end

def method_missing(...)
filter(...)
end

def value(callback = nil)
return @value unless callback

@value = if callback.is_a?(Proc)
callback.(@value)
else
callback
end
self
end
end
end
2 changes: 1 addition & 1 deletion lib/serbea/template_engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ def process_serbea_input(template, properties)
buff << "{% %}\n" # preserve original directive line length
end
else
raise "Handler for Serbea template directive `#{$1}' not found"
raise Serbea::Error, "Handler for Serbea template directive `#{$1}' not found"
end
else
buff << "{% end %}"
Expand Down
2 changes: 1 addition & 1 deletion lib/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module Serbea
VERSION = "1.0.1"
VERSION = "2.0.0"
end
49 changes: 43 additions & 6 deletions test/test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,6 @@ def turbo_stream
end
end


#simple_template = "Hi {{ 'there' }}"
#tmpl = Tilt::SerbeaTemplate.new { simple_template }

Serbea::TemplateEngine.front_matter_preamble = "self.pagedata = YAML.load"
Serbea::TemplateEngine.directive :form, ->(code, buffer) do
model_name, space, params = code.lstrip.partition(%r(\s)m)
Expand Down Expand Up @@ -199,7 +195,6 @@ def turbo_stream
buffer << code
buffer << " %}"
end
#Serbea::Pipeline.raise_on_missing_filters = true

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

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

puts "\nYay! Test passed."
class AnotherClass
def self.operate_on_value(value)
"val #{value} !!"
end
end

class PipelineTemplateTest
include Serbea::Pipeline::Helper

def output
pipe("Hello world") { upcase | split(" ") | value(->{ _1 | ["YO"] }) | test_join(", ") }
end

def test_multiline(input_value)
pipe input_value do
transform_this_way
value ->{ AnotherClass.operate_on_value _1 } # return a new value based on an outside process
now_we_are_done!
end
end

def transform_this_way(input)
input.join("=")
end

def now_we_are_done!(input)
input.upcase
end

def test_join(input, delimeter)
input.join(delimeter)
end
end

pipeline_output = PipelineTemplateTest.new.output
raise "Pipeline broken! #{pipeline_output}" unless
pipeline_output == "HELLO, WORLD, YO"

pipeline_output = PipelineTemplateTest.new.test_multiline(["a", 123])
raise "Multi-line pipeline broken! #{pipeline_output}" unless
pipeline_output == "VAL A=123 !!"

puts "\nYay! Tests passed."

0 comments on commit d488811

Please sign in to comment.