The letter A styled as Alchemists logo. lchemists
Published August 30, 2026 Updated September 15, 2026
Superfluid Icon

Superfluid

0.2.0

Enhances Liquid by smoothing out the rough edges with a strong focus on object composition and functional design. This includes a fully functional filter and tag registry so you can reuse lower level filters and tags within all aspects of your view layer.

Features

  • Uses a customizable default environment with a cleaner Object API.

  • Uses Containable for filter and tag registries.

  • Registers filters via a Module, Functionable, commands (see Command Pattern for details), or containers (see Containable for details).

  • Registers tags via classes or containers (see Containable for details).

  • Provides a default renderer for quick rendering of templates and associated data.

Requirements

Setup

To install with security, run:

# 💡 Skip this line if you already have the public certificate installed.
gem cert --add <(curl --compressed --location https://alchemists.io/gems.pem)
gem install superfluid --trust-policy HighSecurity

To install without security, run:

gem install superfluid

You can also add the gem directly to your project:

bundle add superfluid

Once the gem is installed, you only need to require it:

require "superfluid"

Usage

The quickest way to get started is to create a new instance:

renderer = Superfluid.new

Then you can render your template and associated data:

renderer.call "Hi, this is a {{ value }}.", "value" => "demo"

The above will yield the following output:

"Hi, this is a demo."

The following sections detail how you can configure your environment, register filters, and register tags.

Configuration

The configuration is used by the environment. This is created for you automatically but if you want an instance of the default configuration, use:

configuration = Superfluid::Configuration.new

#<Struct:Superfluid::Configuration:0x00001780
  default_resource_limits = {},
  error_mode = :strict,
  exception_renderer = #<Proc:0x0000000131200ee0 (lambda)>,
  file_system = #<data Superfluid::Systems::Memory:0x000017b0 templates = {}>,
  filter_registry = #<Superfluid::Registries::Filter:0x000000012f5a1290 @container=Superfluid::Filters::Container, @mode=:strict>,
  tag_registry = #<Superfluid::Registries::Tag:0x000000012f5860d0 @container=Superfluid::Tags::Container>
>

As you can see, the configuration is a Struct which means you can customize using any of these attributes:

  • default_resource_limits: Limits the resources that a template can consume. Default: {}.

  • error_mode: The error mode (details). Default: :strict.

  • exception_renderer: The function used to render error messages. Default: Core::Identity (see Core).

  • file_system: The file system. Default: Superfluid::Systems::Memory.new.

  • filter_registry: The filter registry. Default: Superfluid::Registries::Filter.new.

  • tag_registry: The tag registry. Default: Superfluid::Registries::Tag.new.

Environments

To build the default environment, use:

environment = Superfluid.build

To build the default environment directly (which is what the above does), use:

environment = Superfluid::Environment.new

If you’d like to build a custom environment, you can do this several ways. For demonstration purposes, only the error_mode is used below but all configuration attributes are accepted:

environment = Superfluid.build error_mode: :lax
environment = Superfluid::Environment.for error_mode: :lax
environment = Superfluid::Environment.new Superfluid::Configuration[error_mode: :lax]

You can also freeze the environment — which also freezes the filter and tag registries — once you’ve registered any/all filters and tags as follows:

environment.freeze

Systems

These are the file systems used to obtain templates for use within your custom tags. By default, an in memory system is provided for you: Superfluid::Systems::Memory. Liquid also provides it’s own systems as well. Each are described below.

Memory

Provided by this gem. Use to access templates via an in memory file system. This is enabled for you be default when using Superfluid.new and works in conjunction with the template tag which is also provided by this gem (see below for details). Again, this system is provided for you but you can be explicit if desired:

environment = Superfluid.build file_system: Superfluid::Systems::Memory.new
renderer = Superfluid.new(environment:)

template = <<~CONTENT
  {% template demo %}
    Hi, this is a {{ subject }}.
  {% endtemplate %}

  {% render "demo", subject: "demo" %}
CONTENT

renderer.call template
# "Hi, this is a demo."

Local

Provided by Liquid. Use to access templates via your local file system within a specific root folder. Usage:

Pathname("templates").tap(&:mkdir).join("_demo.liquid").tap { it.write "{{ salutation }}" }

environment = Superfluid.build file_system: Liquid::LocalFileSystem.new(Pathname("templates"))
renderer = Superfluid.new(environment:)

renderer.call(%({% include "demo" %}, this is a demo), {"salutation" => "Hi"})
# "Hi, this is a demo"

The above configures the local system to look for partials (templates) within the templates folder. You then reference them via the include tag.

By default, this system uses Kernel#format syntax to find your partials (i.e. "_%s.liquid"). If you don’t like this pattern, you can configure differently when creating a new instance. Example:

Liquid::LocalFileSystem.new "/a/path", "%s.html"

Blank

Provided by Liquid. This is an abstract class — meant for subclassing — because it will fail with a FileSystemError when read_template_file isn’t implemented. Usage:

environment = Superfluid.build file_system: Liquid::BlankFileSystem.new(Pathname("templates"))
renderer = Superfluid.new(environment:)

The above does nothing and is benign.

Filters

By default, Liquid only allows registration of filters as instance methods on a namespace (Module). Unfortunately, this leads to terrible design, bad practices, and unnecessarily hard to test objects. You can still register namespaces but this gem encourages the use of commands (see Command Pattern for further details) and containers via Containable which allows you tap into Functional Programming by using procs, lambdas, and any object that responds to #call. This allows you to build — and reuse — more powerful filters. The following details the different kinds of filters you can use, each broken down by category.

Defaults

Default filters are provided for you automatically and can be viewed via your environment. Example:

environment = Superfluid.build
environment.filter_registry.names

The above will produce a list of all registered filters. These including all filters provided by Liquid and those unique to this gem which are:

jsonify

Renders objects as JSON. Example:

renderer.call(%({{ data | jsonify }}), {"data" => {"a" => 1}})
# "{\"a\":1}"
parse_json

Parses JSON as a primitive. Example:

renderer.call(
  "{% assign data = payload | parse_json %}{{ data.one }}",
  {"payload" => %({"one": 1, "two": 2})}
)

# "1"
pluralize

Renders a plural string. Example:

renderer.call(%({{ text | pluralize: 0 }}), {"text" => "apple"})
# "0 apples"

renderer.call(%({{ text | pluralize: 1 }}), {"text" => "apple"})
# "1 apple"

renderer.call(%({{ text | pluralize: 2 }}), {"text" => "apple"})
# "2 apples"

renderer.call(%({{ text | pluralize: 3, "i", "us" }}), {"text" => "octopus"})
# "3 octopi"
singularize

Renders a singular string. Example:

renderer.call(%({{ text | singularize: 0 }}), {"text" => "apples"})
# "0 apples"

renderer.call(%({{ text | singularize: 1 }}), {"text" => "apples"})
# "1 apple"

renderer.call(%({{ text | singularize: 2 }}), {"text" => "apples"})
# "2 apples"

renderer.call(%({{ text | singularize: 1, "i", "us" }}), {"text" => "octopi"})
# "1 octopus"
trim_end

Renders string with trimmed end. Example:

renderer.call(%({{ text | trim_end: 10 }}), {"text" => "A demo."})
# "A demo."

renderer.call(%({{ text | trim_end: 10 }}), {"text" => "This is a demo."})
# "This is..."

renderer.call(%({{ text | trim_end: 10, "", "---" }}), {"text" => "This is a demo."})
# "This is---"

renderer.call(%({{ text | trim_end: 10, "", "" }}), {"text" => "This is a demo."})
# "This is a"
trim_middle

Renders string with trimmed middle. Example:

renderer.call(%({{ text | trim_middle: 20 }}), {"text" => "This is a demo."})
# "A demo."

renderer.call(%({{ text | trim_middle: 13 }}), {"text" => "This is a demo."})
# "This...demo."

renderer.call(%({{ text | trim_middle: 13, "--" }}), {"text" => "This is a demo."})
# "This--demo."

Modules

Module instance methods are what Liquid supports by default. You only need to define instance methods within a module and then register your module. Example:

# Namespaces
module Primary
  def echo(text) = text
end

module Secondary
  def capitalize(text) = text.capitalize

  def suffix(text, count = 1) = "#{text}-#{count}"
end

# Renderer
renderer = Superfluid.new { it.register_filters Primary, Secondary }

# Results
renderer.call %({{ "demo" | echo }})        # "demo"
renderer.call %({{ "demo" | capitalize }})  # "Demo"
renderer.call %({{ "demo" | suffix }})      # "demo-1"
renderer.call %({{ "demo" | suffix: 2 }})   # "demo-2"

As you can see, all three methods via the two namespaces are properly registered and immediately available for use. You’ll also notice multiple namespaces were registered at once but you can register them individually and/or chain them. Examples:

Superfluid.new do |environment|
  # Single.
  environment.register_filter Primary

  # Multiple.
  environment.register_filters Primary, Secondary

  # Chained.
  environment.register_filter(Primary)
             .register_filter(Secondary)
             .register_filters(Primary, Secondary)
end

Functionables

Functionable modules are identical to the module examples, as shown above, except you require and extend instead. Example:

require "functionable"

# Namespaces
module Primary
  extend Functionable

  def echo(text) = text
end

module Secondary
  extend Functionable

  def capitalize(text) = text.capitalize

  def suffix(text, count = 1) = "#{text}-#{count}"
end

This allows you to use purely functionable methods (see Functionable documentation for details). At this point, you can register your functionable namespaces as follows:

# Renderer
renderer = Superfluid.new { it.register_filters Primary, Secondary }

# Results
renderer.call %({{ "demo" | echo }})        # "demo"
renderer.call %({{ "demo" | capitalize }})  # "Demo"
renderer.call %({{ "demo" | suffix }})      # "demo-1"
renderer.call %({{ "demo" | suffix: 2 }})   # "demo-2"

Registration is identical to modules:

Superfluid.new do |environment|
  # Single.
  environment.register_filter Primary

  # Multiple.
  environment.register_filters Primary, Secondary

  # Chained.
  environment.register_filter(Primary)
             .register_filter(Secondary)
             .register_filters(Primary, Secondary)
end

Procs and Lambdas

Procs and lambdas are not supported by Liquid but are via this gem. Example:

# Setup

echo = proc { it }
capitalize = -> text { text.capitalize }
suffix = -> text, count = 1 { "#{text}-#{count}" }

# Renderer
renderer = Superfluid.new { it.register_filters echo:, capitalize:, suffix: }

# Results

renderer.call %({{ "demo" | echo }})        # "demo"
renderer.call %({{ "demo" | capitalize }})  # "Demo"
renderer.call %({{ "demo" | suffix }})      # "demo-1"
renderer.call %({{ "demo" | suffix: 2 }})   # "demo-2"

Registration can be singular, plural, or chained:

renderer = Superfluid.new do |environment|
  # Single.
  environment.register_filter(echo:)

  # Multiple.
  environment.register_filters(echo:, capitalize:, suffix:)

  # Chained.
  environment.register_filter(echo:)
             .register_filter(capitalize:)
             .register_filters(echo:, capitalize:, suffix:)
end

Classes

Classes, especially composable classes, are not supported by Liquid but are via this gem. You only need to ensure you include Core::Composable and implement the #call method. Example:

require "core"

class Suffixer
  include Core::Composable

  def initialize default: 1
    @default = default
  end

  def call(text, suffix = default) = "#{text}-#{suffix}"

  private

  attr_reader :default
end

Now you can register and render with the above class as follows:

# Renderer
renderer = Superfluid.new { it.register_filters suffix: Suffixer.new }

# Results

renderer.call %({{ "demo" | suffix }})      # "demo-1"
renderer.call %({{ "demo" | suffix: 2 }})   # "demo-2"

Registration can be singular, plural, or chained:

suffix = Suffixer.new

renderer = Superfluid.new do |environment|
  # Single.
  environment.register_filter(suffix:)

  # Multiple.
  environment.register_filters(suffix:)

  # Chained.
  environment.register_filter(suffix:)
             .register_filters(suffix:)
end

Containers

Containers are not supported by Liquid but are via this gem using Containable. To use, create a container and then register your dependencies:

require "containable"

module Container
  extend Containable

  register(:echo) { |text| text }
  register(:suffix) { |text, count = 1| "#{text}-#{count}"  }
end

Now you can merge the container as follows:

renderer = Superfluid.new { it.merge_filters Container }

You can also selectively merge by supplying only the filters you care about from the container:

renderer = Superfluid.new { it.merge_filters Container, :suffix }

Once registered, then you can use as follows:

renderer.call %({{ "demo" | echo }})       # "demo"
renderer.call %({{ "demo" | suffix }})     # "demo-1"
renderer.call %({{ "demo" | suffix: 2 }})  # "demo-2"

With containers, you have the full capabilities of the Containable. The above only scratches the surface of what’s possible.

Tags

As per Liquid documentation, tags only need to inherit from Liquid::Tag and be initialized with tag_name, factor, and tokens parameters. Then you can implement a render method which accepts a context. Example:

# Tag
class Sample < Liquid::Block
  def render(context) = super.sub("<placeholder>", rand(100).to_s)
end

# Renderer
renderer = Superfluid.new { it.register_tag :sample, Sample }

# Results
renderer.call "{% sample %}Your value is: <placeholder>.{% endsample %}"
# "Your value is: 80."

You can also register single or multiple tags at once:

renderer = Superfluid.new do |environment|
  # Single.
  environment.register_tag(:sample, Sample)

  # Multiple.
  environment.register_tags(one: Sample, two: Sample)

  # Chained.
  environment.register_tag(:sample, Sample)
             .register_tags(one: Sample, two: Sample)
end

Defaults

You have access to all tags supported by Liquid including the tags provided by this gem (which is only the template tag at the moment).

template

This tag must be used with the Memory system (see Systems, mentioned earlier, for details). You can use this tag as follows:

renderer = Superfluid.new

template = <<~CONTENT
  {% template demo %}
    Hi, this is a {{ subject }}.
  {% endtemplate %}

  {% render "demo", subject: "demo" %}
CONTENT

renderer.call template
# "Hi, this is a demo."

As you can see this tag requires two steps:

  1. Define by using the template tag followed by a unique name for your template (in this case: demo). You can then use any filters and/or additional tags with your template. Finally, close the tag with endtemplate.

  2. Render your template by using render followed by the unique name of your template (i.e. demo) and any attributes you need to provide to the template.

The above allows you to define multiple templates for rendering later. Great for situations where you want to organize your in memory code for final later rendering.

Containers

Containers are not supported by Liquid but are via this gem using Containable. To use, create a container and then register your dependencies:

require "containable"

module Container
  extend Containable

  register :sample, Sample
end

Now you can merge the container as follows:

renderer = Superfluid.new { it.merge_tags Container }

You can also selectively merge by supplying only the tags you care about from the container:

renderer = Superfluid.new { it.merge_tags Container, :sample }

Once registered, then you can use as follows:

renderer.call "{% sample %}Your value is: <placeholder>.{% endsample %}"
# "Your value is: 85."

With containers, you have the full capabilities of the Containable. The above only scratches the surface of what’s possible.

Development

To contribute, run:

git clone https://github.com/bkuhlmann/superfluid
cd superfluid
bin/setup

You can also use the IRB console for direct access to all objects:

bin/console

Tests

To test, run:

bin/rake

Credits