bbortcodes 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "anyway_config"
4
+
5
+ module BBortcodes
6
+ class Config < Anyway::Config
7
+ config_name :bbortcodes
8
+
9
+ # Error handling strategy when parsing fails
10
+ # :raise - raise ParseError
11
+ # :skip - skip malformed shortcodes and leave them as-is
12
+ # :strip - remove malformed shortcodes from output
13
+ attr_config on_parse_error: :raise
14
+
15
+ # Error handling strategy when encountering disallowed nested shortcodes
16
+ # :raise - raise DisallowedChildError
17
+ # :skip - skip the nested shortcode and leave it as-is
18
+ # :strip - remove the nested shortcode from output
19
+ attr_config on_disallowed_child: :raise
20
+
21
+ # Whether to validate shortcode classes on registration
22
+ attr_config validate_on_register: true
23
+
24
+ # Security settings
25
+
26
+ # Automatically escape HTML in attribute values to prevent XSS
27
+ # Set to false if you need raw HTML (not recommended)
28
+ attr_config auto_escape_attributes: true
29
+
30
+ # Maximum input text size in bytes (default: 1MB)
31
+ attr_config max_input_length: 1_000_000
32
+
33
+ # Parse timeout in seconds to prevent ReDoS attacks
34
+ attr_config parse_timeout: 5
35
+
36
+ # Maximum nesting depth for shortcodes to prevent stack overflow
37
+ attr_config max_nesting_depth: 50
38
+
39
+ # Allow overwriting existing shortcodes in the registry
40
+ attr_config allow_shortcode_overwrite: false
41
+ end
42
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module BBortcodes
6
+ class Context
7
+ include MonitorMixin
8
+
9
+ def initialize(initial_data = {})
10
+ super() # Initialize MonitorMixin
11
+ @data = initial_data.dup
12
+ @counters = Hash.new(0)
13
+ end
14
+
15
+ # Get a value from the context
16
+ # @param key [String, Symbol] The key to look up (normalized to string)
17
+ def get(key)
18
+ synchronize do
19
+ @data[key.to_s]
20
+ end
21
+ end
22
+
23
+ # Set a value in the context
24
+ # @param key [String, Symbol] The key to set (normalized to string)
25
+ # @param value [Object] The value to set
26
+ def set(key, value)
27
+ synchronize do
28
+ @data[key.to_s] = value
29
+ end
30
+ end
31
+
32
+ # Check if a key exists
33
+ # @param key [String, Symbol] The key to check (normalized to string)
34
+ def key?(key)
35
+ synchronize do
36
+ @data.key?(key.to_s)
37
+ end
38
+ end
39
+
40
+ # Increment and return a counter (thread-safe atomic operation)
41
+ # Useful for generating unique IDs like SHORTCODE-1, SHORTCODE-2, etc.
42
+ # @param counter_name [String, Symbol] The counter name (normalized to string)
43
+ # @return [Integer] The incremented counter value
44
+ def increment(counter_name)
45
+ synchronize do
46
+ @counters[counter_name.to_s] += 1
47
+ end
48
+ end
49
+
50
+ # Get current counter value without incrementing
51
+ # @param counter_name [String, Symbol] The counter name (normalized to string)
52
+ # @return [Integer] The current counter value
53
+ def counter(counter_name)
54
+ synchronize do
55
+ @counters[counter_name.to_s]
56
+ end
57
+ end
58
+
59
+ # Reset a counter to 0
60
+ # @param counter_name [String, Symbol] The counter name (normalized to string)
61
+ def reset_counter(counter_name)
62
+ synchronize do
63
+ @counters[counter_name.to_s] = 0
64
+ end
65
+ end
66
+
67
+ # Access all data (returns a duplicate to prevent external modification)
68
+ # @return [Hash] A copy of the context data
69
+ def to_h
70
+ synchronize do
71
+ @data.dup
72
+ end
73
+ end
74
+
75
+ # Allow hash-like access
76
+ def [](key)
77
+ get(key)
78
+ end
79
+
80
+ def []=(key, value)
81
+ set(key, value)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BBortcodes
4
+ class Error < StandardError; end
5
+ class ParseError < Error; end
6
+ class RegistryError < Error; end
7
+ class InvalidShortcodeError < Error; end
8
+ class DisallowedChildError < Error; end
9
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parslet"
4
+
5
+ module BBortcodes
6
+ class Grammar < Parslet::Parser
7
+ # Whitespace
8
+ rule(:space) { match('\s').repeat(1) }
9
+ rule(:space?) { space.maybe }
10
+
11
+ # Attribute value - quoted string
12
+ rule(:quote) { str('"') | str("'") }
13
+ rule(:quoted_string) do
14
+ str('"') >> (str("\\") >> any | str('"').absent? >> any).repeat.as(:string) >> str('"') |
15
+ str("'") >> (str("\\") >> any | str("'").absent? >> any).repeat.as(:string) >> str("'")
16
+ end
17
+
18
+ # Attribute name - alphanumeric and underscore/hyphen
19
+ rule(:attr_name) { match("[a-zA-Z]") >> match("[a-zA-Z0-9_-]").repeat }
20
+
21
+ # Single attribute: name="value"
22
+ rule(:attribute) do
23
+ attr_name.as(:name) >> space? >> str("=") >> space? >> quoted_string.as(:value)
24
+ end
25
+
26
+ # Multiple attributes separated by spaces
27
+ rule(:attributes) do
28
+ (space >> attribute).repeat(1)
29
+ end
30
+
31
+ # Shortcode name - alphanumeric and underscore/hyphen
32
+ rule(:shortcode_name) { match("[a-zA-Z]") >> match("[a-zA-Z0-9_-]").repeat }
33
+
34
+ # Self-closing shortcode: [name] or [name attr="value"]
35
+ rule(:self_closing_shortcode) do
36
+ str("[") >>
37
+ shortcode_name.as(:name) >>
38
+ attributes.as(:attributes).maybe >>
39
+ space? >>
40
+ str("]")
41
+ end
42
+
43
+ # Opening tag: [name] or [name attr="value"]
44
+ rule(:opening_tag) do
45
+ str("[") >>
46
+ shortcode_name.as(:tag_name) >>
47
+ attributes.as(:attributes).maybe >>
48
+ space? >>
49
+ str("]")
50
+ end
51
+
52
+ # Closing tag: [/name]
53
+ rule(:closing_tag) do
54
+ str("[/") >>
55
+ shortcode_name.as(:tag_name) >>
56
+ space? >>
57
+ str("]")
58
+ end
59
+
60
+ # Content between tags - can contain text, nested shortcodes, or paired shortcodes
61
+ rule(:content_char) { str("[").absent? >> any }
62
+ rule(:plain_text) { content_char.repeat(1).as(:text) }
63
+
64
+ # Forward declaration for recursive content
65
+ rule(:content_element) { paired_shortcode | self_closing_shortcode | plain_text }
66
+ rule(:content) { content_element.repeat.as(:content) }
67
+
68
+ # Paired shortcode with content: [name]content[/name]
69
+ rule(:paired_shortcode) do
70
+ opening_tag.as(:opening) >>
71
+ content.maybe >>
72
+ closing_tag.as(:closing)
73
+ end
74
+
75
+ # Document is a sequence of text and shortcodes
76
+ rule(:document_element) { paired_shortcode | self_closing_shortcode | plain_text }
77
+ rule(:document) { document_element.repeat.as(:document) }
78
+
79
+ root(:document)
80
+ end
81
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module BBortcodes
6
+ class Parser
7
+ attr_reader :registry, :config
8
+
9
+ def initialize(registry: nil, config: nil)
10
+ @registry = registry || BBortcodes.registry
11
+ @config = config || BBortcodes.config
12
+ @grammar = Grammar.new
13
+ @transform = Transform.new
14
+ end
15
+
16
+ # Parse a string containing shortcodes
17
+ # @param text [String] The text to parse
18
+ # @param context [Context, Hash, nil] The rendering context
19
+ # @param only [Array<String>, nil] Only process these shortcode types
20
+ # @return [Array<String, Array<Shortcode>>] The processed text and array of shortcode instances
21
+ def parse(text, context: nil, only: nil)
22
+ # Validate input size
23
+ max_length = config.max_input_length
24
+ if text.bytesize > max_length
25
+ raise ParseError, "Input too large: #{text.bytesize} bytes (max: #{max_length})"
26
+ end
27
+
28
+ context = prepare_context(context)
29
+
30
+ # Parse the text into a tree structure with timeout protection
31
+ begin
32
+ parsed = Timeout.timeout(config.parse_timeout) do
33
+ @grammar.parse(text)
34
+ end
35
+ rescue Timeout::Error
36
+ raise ParseError, "Parsing timeout exceeded (#{config.parse_timeout}s) - input may be too complex"
37
+ rescue Parslet::ParseFailed => e
38
+ handle_parse_error(e, text)
39
+ return [text, []]
40
+ end
41
+
42
+ # Transform the parse tree
43
+ tree = @transform.apply(parsed)
44
+
45
+ # Convert to shortcode instances and render
46
+ shortcodes = []
47
+ output = process_tree(tree, context, only, shortcodes, parent_shortcode: nil, depth: 0)
48
+
49
+ [output, shortcodes]
50
+ end
51
+
52
+ private
53
+
54
+ def prepare_context(context)
55
+ case context
56
+ when Context
57
+ context
58
+ when Hash
59
+ Context.new(context)
60
+ when nil
61
+ Context.new
62
+ else
63
+ raise ArgumentError, "context must be a Context, Hash, or nil"
64
+ end
65
+ end
66
+
67
+ def handle_parse_error(error, text)
68
+ case config.on_parse_error
69
+ when :raise
70
+ raise ParseError, "Failed to parse shortcodes: #{error.message}"
71
+ when :skip, :strip
72
+ # Return original text or empty string
73
+ nil
74
+ else
75
+ raise ArgumentError, "Invalid on_parse_error setting: #{config.on_parse_error}"
76
+ end
77
+ end
78
+
79
+ def process_tree(tree, context, only, shortcodes, parent_shortcode:, depth:)
80
+ return "" if tree.nil?
81
+
82
+ # Check nesting depth
83
+ if depth > config.max_nesting_depth
84
+ raise ParseError, "Maximum nesting depth exceeded (#{config.max_nesting_depth})"
85
+ end
86
+
87
+ tree.map do |node|
88
+ process_node(node, context, only, shortcodes, parent_shortcode: parent_shortcode, depth: depth)
89
+ end.join
90
+ end
91
+
92
+ def process_node(node, context, only, shortcodes, parent_shortcode:, depth:)
93
+ case node[:type]
94
+ when :text
95
+ node[:value]
96
+ when :shortcode
97
+ process_shortcode(node, context, only, shortcodes, parent_shortcode: parent_shortcode, depth: depth)
98
+ else
99
+ ""
100
+ end
101
+ end
102
+
103
+ def process_shortcode(node, context, only, shortcodes, parent_shortcode:, depth:)
104
+ # Check nesting depth
105
+ if depth > config.max_nesting_depth
106
+ raise ParseError, "Maximum nesting depth exceeded (#{config.max_nesting_depth})"
107
+ end
108
+
109
+ tag_name = node[:name]
110
+
111
+ # Check if this shortcode should be processed based on 'only' filter
112
+ if only && !only.include?(tag_name)
113
+ return reconstruct_shortcode_text(node)
114
+ end
115
+
116
+ # Find the shortcode class
117
+ shortcode_class = registry.find(tag_name)
118
+ unless shortcode_class
119
+ return handle_unknown_shortcode(node)
120
+ end
121
+
122
+ # Validate against parent's allowed children
123
+ if parent_shortcode && !parent_shortcode.class.allows_child?(shortcode_class)
124
+ return handle_disallowed_child(node, parent_shortcode)
125
+ end
126
+
127
+ # Process nested content with increased depth
128
+ processed_content = if node[:content]
129
+ process_content(node[:content], context, only, shortcodes, shortcode_class, depth: depth + 1)
130
+ else
131
+ []
132
+ end
133
+
134
+ # Create shortcode instance
135
+ shortcode = shortcode_class.new(
136
+ name: tag_name,
137
+ attributes: node[:attributes] || {},
138
+ content: processed_content,
139
+ self_closing: node[:self_closing]
140
+ )
141
+
142
+ # Add to collection
143
+ shortcodes << shortcode
144
+
145
+ # Render the shortcode
146
+ shortcode.render(context)
147
+ end
148
+
149
+ def process_content(content_nodes, context, only, shortcodes, parent_shortcode_class, depth:)
150
+ return [] if content_nodes.nil? || content_nodes.empty?
151
+
152
+ # Create a temporary parent shortcode for validation
153
+ parent = Object.new
154
+ parent.define_singleton_method(:class) { parent_shortcode_class }
155
+
156
+ content_nodes.map do |node|
157
+ case node[:type]
158
+ when :text
159
+ node[:value]
160
+ when :shortcode
161
+ # For nested shortcodes, we need to process them and collect them
162
+ process_shortcode(node, context, only, shortcodes, parent_shortcode: parent, depth: depth)
163
+ else
164
+ ""
165
+ end
166
+ end
167
+ end
168
+
169
+ def handle_unknown_shortcode(node)
170
+ case config.on_parse_error
171
+ when :raise
172
+ raise ParseError, "Unknown shortcode: #{node[:name]}"
173
+ when :skip
174
+ reconstruct_shortcode_text(node)
175
+ when :strip
176
+ ""
177
+ end
178
+ end
179
+
180
+ def handle_disallowed_child(node, parent)
181
+ case config.on_disallowed_child
182
+ when :raise
183
+ raise DisallowedChildError,
184
+ "Shortcode '#{node[:name]}' is not allowed as a child of '#{parent.class.tag_name}'"
185
+ when :skip
186
+ reconstruct_shortcode_text(node)
187
+ when :strip
188
+ ""
189
+ end
190
+ end
191
+
192
+ # Reconstruct the original shortcode text from a parsed node
193
+ def reconstruct_shortcode_text(node)
194
+ attrs = format_attributes(node[:attributes])
195
+ if node[:self_closing]
196
+ "[#{node[:name]}#{attrs}]"
197
+ else
198
+ content = reconstruct_content(node[:content])
199
+ "[#{node[:name]}#{attrs}]#{content}[/#{node[:name]}]"
200
+ end
201
+ end
202
+
203
+ def format_attributes(attributes)
204
+ return "" if attributes.nil? || attributes.empty?
205
+
206
+ " " + attributes.map { |k, v| "#{k}=\"#{v}\"" }.join(" ")
207
+ end
208
+
209
+ def reconstruct_content(content_nodes)
210
+ return "" if content_nodes.nil? || content_nodes.empty?
211
+
212
+ content_nodes.map do |node|
213
+ case node[:type]
214
+ when :text
215
+ node[:value]
216
+ when :shortcode
217
+ reconstruct_shortcode_text(node)
218
+ else
219
+ ""
220
+ end
221
+ end.join
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module BBortcodes
6
+ class Registry
7
+ include MonitorMixin
8
+
9
+ def initialize
10
+ super
11
+ @shortcodes = {}
12
+ end
13
+
14
+ # Register a shortcode class
15
+ # @param shortcode_class [Class] A class that inherits from Shortcode
16
+ def register(shortcode_class)
17
+ synchronize do
18
+ unless shortcode_class < Shortcode
19
+ raise RegistryError, "#{shortcode_class} must inherit from BBortcodes::Shortcode"
20
+ end
21
+
22
+ tag_name = shortcode_class.tag_name
23
+
24
+ if @shortcodes.key?(tag_name)
25
+ if BBortcodes.config.allow_shortcode_overwrite
26
+ @shortcodes.delete(tag_name)
27
+ else
28
+ raise RegistryError,
29
+ "Shortcode '#{tag_name}' is already registered by #{@shortcodes[tag_name].name}. " \
30
+ "Set allow_shortcode_overwrite: true in config to override, or call unregister('#{tag_name}') first."
31
+ end
32
+ end
33
+
34
+ @shortcodes[tag_name] = shortcode_class
35
+ end
36
+ end
37
+
38
+ # Find a shortcode class by tag name
39
+ # @param tag_name [String] The tag name to look up
40
+ # @return [Class, nil] The shortcode class or nil if not found
41
+ def find(tag_name)
42
+ synchronize do
43
+ @shortcodes[tag_name.to_s]
44
+ end
45
+ end
46
+
47
+ # Check if a shortcode is registered
48
+ # @param tag_name [String] The tag name to check
49
+ # @return [Boolean]
50
+ def registered?(tag_name)
51
+ synchronize do
52
+ @shortcodes.key?(tag_name.to_s)
53
+ end
54
+ end
55
+
56
+ # Get all registered shortcode classes
57
+ # @return [Hash] Hash of tag_name => shortcode_class
58
+ def all
59
+ synchronize do
60
+ @shortcodes.dup
61
+ end
62
+ end
63
+
64
+ # Get all registered tag names
65
+ # @return [Array<String>]
66
+ def tag_names
67
+ synchronize do
68
+ @shortcodes.keys
69
+ end
70
+ end
71
+
72
+ # Unregister a shortcode
73
+ # @param tag_name [String] The tag name to unregister
74
+ def unregister(tag_name)
75
+ synchronize do
76
+ @shortcodes.delete(tag_name.to_s)
77
+ end
78
+ end
79
+
80
+ # Clear all registered shortcodes
81
+ def clear
82
+ synchronize do
83
+ @shortcodes.clear
84
+ end
85
+ end
86
+
87
+ # Get the count of registered shortcodes
88
+ # @return [Integer]
89
+ def count
90
+ synchronize do
91
+ @shortcodes.size
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "literal"
4
+ require "cgi"
5
+
6
+ module BBortcodes
7
+ class Shortcode < Literal::Object
8
+ prop :name, String, reader: :public
9
+ prop :attributes, Hash, reader: :public
10
+ prop :content, Array, default: -> { [] }, reader: :public
11
+ prop :self_closing, _Boolean, default: -> { false }, reader: :public
12
+
13
+ class << self
14
+ #
15
+ # Override in subclasses to specify the shortcode tag name
16
+ # If not overridden, uses the class name converted to snake_case
17
+ #
18
+ def tag_name
19
+ name.split("::").last
20
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
21
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
22
+ .downcase
23
+ end
24
+
25
+ #
26
+ # Override in subclasses to specify allowed child shortcodes
27
+ # Return :all to allow all children
28
+ # Return [] to allow no children (content is just text)
29
+ # Return array of shortcode classes to allow specific children
30
+ #
31
+ def allowed_children
32
+ :all
33
+ end
34
+
35
+ # Check if a given shortcode class is allowed as a child
36
+ def allows_child?(shortcode_class)
37
+ return true if allowed_children == :all
38
+ return false if allowed_children.empty?
39
+
40
+ allowed_children.include?(shortcode_class)
41
+ end
42
+
43
+ # @!macro [attach] shortcode_attribute_reader
44
+ # @!method $1
45
+ # Retrieves the value of the shortcode attribute.
46
+ # This method is generated by {ClassMethods#shortcode_attribute_reader} and calls
47
+ # {#attribute} internally, respecting HTML escaping configuration.
48
+ # @return [String, nil] the attribute value (HTML-escaped by default based on config)
49
+ # @see #attribute
50
+ #
51
+ # Defines a helper method to retrieve a shortcode attribute.
52
+ #
53
+ # This macro creates an instance method that retrieves the specified attribute value.
54
+ # The generated method respects the +auto_escape_attributes+ configuration setting.
55
+ #
56
+ # @param name [Symbol] The method name to define
57
+ # @param attribute_name [Symbol, String] The attribute name to retrieve (defaults to method name)
58
+ # @return [void]
59
+ #
60
+ # @example Basic usage
61
+ # class VideoShortcode < BBortcodes::Shortcode
62
+ # shortcode_attribute_reader :url
63
+ #
64
+ # def render(context)
65
+ # "<video src=\"#{url}\"></video>"
66
+ # end
67
+ # end
68
+ #
69
+ # @example Custom attribute name mapping
70
+ # class ImageShortcode < BBortcodes::Shortcode
71
+ # shortcode_attribute_reader :image_url, attribute_name: "url"
72
+ #
73
+ # def render(context)
74
+ # "<img src=\"#{image_url}\" />"
75
+ # end
76
+ # end
77
+ #
78
+ # @example Multiple attribute readers
79
+ # class ButtonShortcode < BBortcodes::Shortcode
80
+ # shortcode_attribute_reader :href
81
+ # shortcode_attribute_reader :color
82
+ # shortcode_attribute_reader :size
83
+ #
84
+ # def render(context)
85
+ # "<a href=\"#{href}\" class=\"btn-#{color} btn-#{size}\">Click</a>"
86
+ # end
87
+ # end
88
+ def shortcode_attribute_reader(name, attribute_name: name)
89
+ define_method(name) { attribute(attribute_name) }
90
+ end
91
+ end
92
+
93
+ #
94
+ # Render the shortcode to a string
95
+ # Must be implemented by subclasses
96
+ # @param context [Context] The rendering context
97
+ # @return [String] The rendered output
98
+ #
99
+ def render(context)
100
+ raise NotImplementedError, "#{self.class.name} must implement #render"
101
+ end
102
+
103
+ #
104
+ # Helper method to render content (for paired shortcodes)
105
+ # This will recursively render nested shortcodes and text
106
+ # @param context [Context] The rendering context
107
+ # @param parser [Parser] The parser instance for rendering nested shortcodes
108
+ # @return [String] The rendered content
109
+ #
110
+ def render_content(context, parser)
111
+ return "" if @content.nil? || @content.empty?
112
+
113
+ @content.map do |node|
114
+ case node
115
+ when Shortcode
116
+ node.render(context)
117
+ when String
118
+ node
119
+ when Hash
120
+ # Handle raw parsed nodes that haven't been converted to Shortcode instances yet
121
+ if node[:type] == :text
122
+ node[:value]
123
+ else
124
+ # This is a parsed shortcode node, let the parser handle it
125
+ ""
126
+ end
127
+ else
128
+ node.to_s
129
+ end
130
+ end.join
131
+ end
132
+
133
+ #
134
+ # Escape HTML entities to prevent XSS attacks
135
+ # @param text [String] The text to escape
136
+ # @return [String] HTML-escaped text
137
+ #
138
+ def escape_html(text)
139
+ return text if text.nil?
140
+ CGI.escapeHTML(text.to_s)
141
+ end
142
+
143
+ #
144
+ # Get the value of an attribute with optional default
145
+ # Automatically escapes HTML if auto_escape_attributes is enabled
146
+ # @param name [String, Symbol] The attribute name
147
+ # @param default [Object] Default value if attribute not found
148
+ # @param escape [Boolean] Override auto-escape setting for this attribute
149
+ # @return [String, Object] The attribute value
150
+ #
151
+ def attribute(name, default = nil, escape: nil)
152
+ value = @attributes.fetch(name.to_s, default)
153
+ return value if value.nil?
154
+
155
+ # Determine if we should escape
156
+ should_escape = escape.nil? ? BBortcodes.config.auto_escape_attributes : escape
157
+
158
+ should_escape ? escape_html(value) : value
159
+ end
160
+
161
+ # Check if an attribute exists
162
+ def has_attribute?(name)
163
+ @attributes.key?(name.to_s)
164
+ end
165
+ end
166
+ end