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)
= attribute("author")
html = "<blockquote>#{content_html}"
html += "<cite>#{}</cite>" if
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><script>alert(1)</script></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
- Always escape user input: Keep
auto_escape_attributes: true(default) - Set appropriate limits: Adjust
max_input_length,parse_timeout, andmax_nesting_depthbased on your use case - Validate shortcode sources: Only parse content from trusted sources or properly sanitize user input
- Use
only:filter: When parsing untrusted content, use theonly:parameter to limit which shortcodes can be processed - 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.