diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..25de227 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/docs/frontend/styles/index.scss b/docs/frontend/styles/index.scss index d277995..569deb6 100644 --- a/docs/frontend/styles/index.scss +++ b/docs/frontend/styles/index.scss @@ -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%; diff --git a/docs/server/roda_app.rb b/docs/server/roda_app.rb index 73861a2..a9239e5 100644 --- a/docs/server/roda_app.rb +++ b/docs/server/roda_app.rb @@ -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 diff --git a/docs/src/index.md b/docs/src/index.md index df04902..6e6c3b8 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -9,6 +9,12 @@ layout: home **Serbea**. Finally, something to crow(n) about. _Le roi est mort, vive le roi!_ + + ### Table of Contents {:.no_toc} * … @@ -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" %} @@ -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 @@ -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. diff --git a/lib/serbea.rb b/lib/serbea.rb index c16ed70..1d4b83c 100644 --- a/lib/serbea.rb +++ b/lib/serbea.rb @@ -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" diff --git a/lib/serbea/helpers.rb b/lib/serbea/helpers.rb index 39e4d45..f4138db 100644 --- a/lib/serbea/helpers.rb +++ b/lib/serbea/helpers.rb @@ -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 diff --git a/lib/serbea/pipeline.rb b/lib/serbea/pipeline.rb index c2bb36d..945aa52 100644 --- a/lib/serbea/pipeline.rb +++ b/lib/serbea/pipeline.rb @@ -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] @@ -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 @@ -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 diff --git a/lib/serbea/template_engine.rb b/lib/serbea/template_engine.rb index 661acd9..5dd97cf 100644 --- a/lib/serbea/template_engine.rb +++ b/lib/serbea/template_engine.rb @@ -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 %}" diff --git a/lib/version.rb b/lib/version.rb index 6b1292b..f129b85 100644 --- a/lib/version.rb +++ b/lib/version.rb @@ -1,3 +1,3 @@ module Serbea - VERSION = "1.0.1" + VERSION = "2.0.0" end \ No newline at end of file diff --git a/test/test.rb b/test/test.rb index e570df0..bbcf8de 100644 --- a/test/test.rb +++ b/test/test.rb @@ -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) @@ -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")) @@ -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."