Gem Version

BBortcodes

A state-of-the-art Ruby gem for parsing WordPress-like shortcodes with grammar-based parsing, type safety, and support for nested shortcodes.

Features

  • Grammar-based parsing using Parslet - only registered shortcodes are parsed, everything else passes through as plain text
  • Type safety with Literal for runtime type checking
  • Nested shortcodes with configurable child validation
  • Self-closing and paired shortcodes support
  • Named attributes with quoted values
  • Shared rendering context for state management across shortcodes
  • Flexible error handling via Anyway Config
  • Pure Ruby - no Rails dependencies required
  • Thread-safe shortcode registry

Installation

Add this line to your application's Gemfile:

gem 'bbortcodes'

And then execute:

bundle install

Or install it yourself as:

gem install bbortcodes

Quick Start

1. Define a Shortcode

class YoutubeShortcode < BBortcodes::Shortcode
  def self.tag_name
    "youtube"
  end

  def render(context)
    url = attribute("url")
    "<iframe src=\"#{url}\" frameborder=\"0\"></iframe>"
  end
end

2. Register the Shortcode

BBortcodes.register(YoutubeShortcode)

3. Parse Text

text = "Check out this video: [youtube url=\"https://youtube.com/watch?v=abc\"]"
output, shortcodes = BBortcodes.parse(text)

puts output
# => "Check out this video: <iframe src=\"https://youtube.com/watch?v=abc\" frameborder=\"0\"></iframe>"

puts shortcodes.length
# => 1

puts shortcodes.first.class
# => YoutubeShortcode

Usage

Defining Shortcodes

All shortcodes inherit from BBortcodes::Shortcode and use Literal for type safety:

class BoldShortcode < BBortcodes::Shortcode
  # Optional: customize the tag name (defaults to snake_case of class name)
  def self.tag_name
    "bold"
  end

  # Required: implement the render method
  def render(context)
    content_text = render_content(context, nil)
    "<strong>#{content_text}</strong>"
  end
end

Types of Shortcodes

Self-Closing Shortcodes

# Usage: [image url="photo.jpg" alt="A photo"]

class ImageShortcode < BBortcodes::Shortcode
  def self.tag_name
    "image"
  end

  def render(context)
    url = attribute("url")
    alt = attribute("alt", "")
    "<img src=\"#{url}\" alt=\"#{alt}\" />"
  end
end

Paired Shortcodes with Content

# Usage: [bold]This is bold text[/bold]

class BoldShortcode < BBortcodes::Shortcode
  def self.tag_name
    "bold"
  end

  def render(context)
    content_text = render_content(context, nil)
    "<strong>#{content_text}</strong>"
  end
end

Shortcodes with Attributes and Content

# Usage: [button url="/signup" color="blue"]Sign Up[/button]

class ButtonShortcode < BBortcodes::Shortcode
  def self.tag_name
    "button"
  end

  def render(context)
    url = attribute("url", "#")
    color = attribute("color", "primary")
    text = render_content(context, nil)

    "<a href=\"#{url}\" class=\"btn btn-#{color}\">#{text}</a>"
  end
end

Nested Shortcodes

Control which shortcodes can be nested within others:

# Gallery that only allows Image children
class GalleryShortcode < BBortcodes::Shortcode
  def self.tag_name
    "gallery"
  end

  # Only allow ImageShortcode children
  def self.allowed_children
    [ImageShortcode]
  end

  def render(context)
    content_html = render_content(context, nil)
    "<div class=\"gallery\">#{content_html}</div>"
  end
end

# Allow any children
class QuoteShortcode < BBortcodes::Shortcode
  def self.tag_name
    "quote"
  end

  def self.allowed_children
    :all # Allow any nested shortcodes
  end

  def render(context)
    content_html = render_content(context, nil)
    author = attribute("author")

    html = "<blockquote>#{content_html}"
    html += "<cite>#{author}</cite>" if author
    html += "</blockquote>"
    html
  end
end

# Allow no children (content is plain text only)
class CodeShortcode < BBortcodes::Shortcode
  def self.tag_name
    "code"
  end

  def self.allowed_children
    [] # No nested shortcodes allowed
  end

  def render(context)
    # Content will only be plain text
    content_text = render_content(context, nil)
    "<code>#{content_text}</code>"
  end
end

Using Context for Shared State

The context allows shortcodes to share state during rendering:

class NumberedItemShortcode < BBortcodes::Shortcode
  def self.tag_name
    "item"
  end

  def render(context)
    # Increment a counter
    number = context.increment(:item_counter)

    content_text = render_content(context, nil)
    "<div class=\"item\">#{number}. #{content_text}</div>"
  end
end

# Usage
context = BBortcodes::Context.new
text = "[item]First[/item] [item]Second[/item] [item]Third[/item]"
output, _ = BBortcodes.parse(text, context: context)

# Output:
# <div class="item">1. First</div> <div class="item">2. Second</div> <div class="item">3. Third</div>

Placeholder Pattern (API Use Case)

For APIs that need to separate shortcode data from content, you can use the returned shortcodes array:

class VideoShortcode < BBortcodes::Shortcode
  def self.tag_name
    "video"
  end

  def render(context)
    # Generate unique placeholder ID using context counter
    id = context.increment(:shortcode)
    "{{SHORTCODE-#{id}}}"
  end
end

# Usage
context = BBortcodes::Context.new
text = "Watch this: [video url=\"video.mp4\" thumbnail=\"thumb.jpg\"]"
output, shortcodes = BBortcodes.parse(text, context: context)

puts output
# => "Watch this: {{SHORTCODE-1}}"

# Build API response data directly from the shortcodes array
shortcodes_data = {}
shortcodes.each_with_index do |shortcode, index|
  id = index + 1
  shortcodes_data["SHORTCODE-#{id}"] = {
    type: shortcode.name,
    attributes: shortcode.attributes,
    self_closing: shortcode.self_closing
  }
end

# In your API response:
{
  content: output,
  shortcodes: shortcodes_data
}
# => {
#   content: "Watch this: {{SHORTCODE-1}}",
#   shortcodes: {
#     "SHORTCODE-1" => {
#       type: "video",
#       attributes: {"url" => "video.mp4", "thumbnail" => "thumb.jpg"},
#       self_closing: true
#     }
#   }
# }

Note: The context object can still be used for shared state (like the counter above) or for passing data between nested shortcodes, but for API responses, it's better to use the returned shortcodes array to access all shortcode data.

Filtering Shortcodes

Process only specific shortcode types using the only: option:

text = "Text with [youtube url=\"video.mp4\"] and [button url=\"/click\"]Click[/button]"

# Only process youtube shortcodes, leave others as-is
output, shortcodes = BBortcodes.parse(text, only: ["youtube"])

puts output
# => "Text with <iframe...> and [button url=\"/click\"]Click[/button]"

puts shortcodes.length
# => 1 (only youtube was processed)

Security

BBortcodes includes multiple security features to protect against common attacks:

HTML Escaping (XSS Protection)

By default, all attribute values are automatically HTML-escaped to prevent Cross-Site Scripting (XSS) attacks:

text = '[quote author="<script>alert(1)</script>"]Hello[/quote]'
output, _ = BBortcodes.parse(text)

# Output: <blockquote>Hello<cite>&lt;script&gt;alert(1)&lt;/script&gt;</cite></blockquote>
# The script tag is escaped and won't execute

You can disable escaping for specific attributes when you need raw HTML:

class MyShortcode < BBortcodes::Shortcode
  def render(context)
    # Escaped by default
    safe_value = attribute("safe_attr")

    # Explicitly disable escaping for this attribute
    raw_html = attribute("html_attr", escape: false)

    # Or use escape_html() helper manually
    user_input = escape_html(attribute("user_attr", escape: false))
  end
end

Warning: Only disable escaping when you fully control the input source. Never disable escaping for user-provided content.

Input Size Limits

BBortcodes limits the maximum input size to prevent memory exhaustion:

BBortcodes.configure do |config|
  config.max_input_length = 1_000_000  # 1MB default
end

# Inputs larger than the limit will raise BBortcodes::ParseError

Parse Timeout Protection

Parsing is protected by a timeout to prevent ReDoS (Regular Expression Denial of Service) attacks:

BBortcodes.configure do |config|
  config.parse_timeout = 5  # 5 seconds default
end

# Complex inputs that take too long to parse will raise BBortcodes::ParseError

Nesting Depth Limits

Maximum nesting depth prevents stack overflow from deeply nested shortcodes:

BBortcodes.configure do |config|
  config.max_nesting_depth = 50  # default
end

# Deeply nested shortcodes exceeding the limit will raise BBortcodes::ParseError

Registry Overwrite Protection

By default, shortcodes cannot be silently overwritten in the registry:

BBortcodes.register(MyShortcode)
BBortcodes.register(MyShortcode)  # Raises BBortcodes::RegistryError

# Allow overwrites if needed
BBortcodes.configure do |config|
  config.allow_shortcode_overwrite = true
end

Thread Safety

Both the Registry and Context classes are thread-safe using Ruby's Monitor:

# Safe to use across threads
context = BBortcodes::Context.new

threads = 10.times.map do
  Thread.new do
    context.increment(:counter)
  end
end

threads.each(&:join)
puts context.counter(:counter)  # Reliably outputs 10

Security Best Practices

  1. Always escape user input: Keep auto_escape_attributes: true (default)
  2. Set appropriate limits: Adjust max_input_length, parse_timeout, and max_nesting_depth based on your use case
  3. Validate shortcode sources: Only parse content from trusted sources or properly sanitize user input
  4. Use only: filter: When parsing untrusted content, use the only: parameter to limit which shortcodes can be processed
  5. Audit custom shortcodes: Ensure your custom shortcode render() methods properly escape output

Configuration

Configure error handling and security settings:

BBortcodes.configure do |config|
  # Error handling
  # ---------------

  # How to handle grammar-level parse failures
  # Options: :raise, :skip (return original text), :strip (return empty)
  # Note: Unregistered shortcodes like [unknown] are automatically plain text
  config.on_parse_error = :raise # default

  # How to handle disallowed nested shortcodes
  # Options: :raise, :skip (leave as-is), :strip (remove)
  config.on_disallowed_child = :raise # default

  # Validate shortcode classes on registration
  config.validate_on_register = true # default

  # Security settings
  # -----------------

  # Automatically escape HTML in attribute values to prevent XSS
  config.auto_escape_attributes = true # default (recommended)

  # Maximum input text size in bytes (default: 1MB)
  config.max_input_length = 1_000_000

  # Parse timeout in seconds to prevent ReDoS attacks
  config.parse_timeout = 5

  # Maximum nesting depth for shortcodes to prevent stack overflow
  config.max_nesting_depth = 50

  # Allow overwriting existing shortcodes in the registry
  config.allow_shortcode_overwrite = false # default (recommended)
end

Unregistered Shortcodes as Plain Text

Bracket patterns that don't match registered shortcode names are automatically treated as plain text:

text = "Text with [unknown]shortcode[/unknown] and [link](url)"
output, _ = BBortcodes.parse(text)

puts output
# => "Text with [unknown]shortcode[/unknown] and [link](url)"
# Both patterns pass through unchanged since they're not registered shortcodes

This means markdown links, unregistered shortcode names, and other bracket patterns won't cause parse errors—they simply remain as plain text in the output.

Example: Strip Disallowed Children

BBortcodes.configure do |config|
  config.on_disallowed_child = :strip
end

# Assuming GalleryShortcode only allows ImageShortcode children
text = "[gallery][image url=\"1.jpg\"][bold]Invalid[/bold][/gallery]"
output, _ = BBortcodes.parse(text)

puts output
# => "<div class=\"gallery\"><img src=\"1.jpg\" /></div>"
# The [bold] shortcode was stripped

Environment-based Configuration

Using Anyway Config, you can configure via environment variables or YAML:

# Environment variables
BBORTCODES_ON_PARSE_ERROR=skip
BBORTCODES_ON_DISALLOWED_CHILD=strip

Or via config/bbortcodes.yml:

production:
  on_parse_error: skip
  on_disallowed_child: skip

development:
  on_parse_error: raise
  on_disallowed_child: raise

Advanced Usage

Custom Parser Instance

Create isolated parser instances with custom registries:

# Create a custom registry
registry = BBortcodes::Registry.new
registry.register(MyShortcode)

# Create a parser with custom registry
parser = BBortcodes::Parser.new(registry: registry)
output, shortcodes = parser.parse(text)

Accessing Attributes

Several helper methods are available for working with attributes:

class MyShortcode < BBortcodes::Shortcode
  def render(context)
    # Get attribute with default value
    color = attribute("color", "blue")

    # Check if attribute exists
    if has_attribute?("url")
      url = attribute("url")
    end

    # Access all attributes
    attributes.each do |key, value|
      puts "#{key}: #{value}"
    end
  end
end

Working with Content

class WrapperShortcode < BBortcodes::Shortcode
  def render(context)
    # Check if shortcode has content
    if content.nil? || content.empty?
      return "<div>No content</div>"
    end

    # Render nested content
    content_html = render_content(context, nil)

    "<div class=\"wrapper\">#{content_html}</div>"
  end
end

Architecture

Grammar-based Parsing

BBortcodes uses Parslet, a PEG (Parsing Expression Grammar) parser, instead of regular expressions. This provides:

  • Robust parsing of complex nested structures
  • Better error messages when syntax is invalid
  • Composable grammar rules for maintainability
  • Unambiguous parsing of edge cases
  • Dynamic tag matching - the grammar only matches registered shortcode names, treating everything else (markdown links, unregistered patterns) as plain text

Type Safety

Using Literal, shortcode properties are type-checked at runtime:

shortcode = MyShortcode.new(
  name: "my_shortcode",
  attributes: {"key" => "value"},
  content: [],
  self_closing: false
)

# Properties are type-checked
shortcode.name # => String
shortcode.attributes # => Hash
shortcode.content # => Array
shortcode.self_closing # => Boolean

Thread Safety

The global registry uses a Monitor for thread-safe concurrent access:

# Safe to use across threads
Thread.new { BBortcodes.register(Shortcode1) }
Thread.new { BBortcodes.register(Shortcode2) }

Development

After checking out the repo, run:

bundle install

Run the examples:

ruby examples/basic_shortcodes.rb
ruby examples/error_handling.rb

Run tests:

bundle exec rspec

Run linter:

bundle exec standardrb

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/yourusername/bbortcodes.

License

The gem is available as open source under the terms of the MIT License.