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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +38 -0
- data/LICENSE.txt +21 -0
- data/README.md +648 -0
- data/lib/bbortcodes/config.rb +42 -0
- data/lib/bbortcodes/context.rb +84 -0
- data/lib/bbortcodes/errors.rb +9 -0
- data/lib/bbortcodes/grammar.rb +81 -0
- data/lib/bbortcodes/parser.rb +224 -0
- data/lib/bbortcodes/registry.rb +95 -0
- data/lib/bbortcodes/shortcode.rb +166 -0
- data/lib/bbortcodes/transform.rb +101 -0
- data/lib/bbortcodes/version.rb +5 -0
- data/lib/bbortcodes.rb +57 -0
- metadata +158 -0
|
@@ -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,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
|