markbridge 0.2.0 → 0.3.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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/lib/markbridge/ast/quote.rb +45 -15
  3. data/lib/markbridge/ast/text.rb +7 -4
  4. data/lib/markbridge/ast/url.rb +15 -0
  5. data/lib/markbridge/parsers/bbcode/handler_registry.rb +27 -1
  6. data/lib/markbridge/parsers/bbcode/handlers/quote_handler.rb +32 -9
  7. data/lib/markbridge/parsers/bbcode/handlers/raw_handler.rb +8 -4
  8. data/lib/markbridge/parsers/bbcode/parser.rb +6 -3
  9. data/lib/markbridge/parsers/bbcode/scanner.rb +133 -53
  10. data/lib/markbridge/parsers/html/handler_registry.rb +27 -1
  11. data/lib/markbridge/parsers/html/parser.rb +54 -13
  12. data/lib/markbridge/parsers/media_wiki/inline_parser.rb +95 -57
  13. data/lib/markbridge/parsers/media_wiki/inline_tag_registry.rb +24 -1
  14. data/lib/markbridge/parsers/media_wiki/parser.rb +5 -2
  15. data/lib/markbridge/parsers/text_formatter/handler_registry.rb +23 -1
  16. data/lib/markbridge/parsers/text_formatter/handlers/quote_handler.rb +31 -2
  17. data/lib/markbridge/parsers/text_formatter/parser.rb +10 -3
  18. data/lib/markbridge/renderers/discourse/markdown_escaper.rb +12 -1
  19. data/lib/markbridge/renderers/discourse/render_context.rb +74 -11
  20. data/lib/markbridge/renderers/discourse/renderer.rb +63 -13
  21. data/lib/markbridge/renderers/discourse/rendering_interface.rb +27 -2
  22. data/lib/markbridge/renderers/discourse/tag_library.rb +20 -0
  23. data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +8 -6
  24. data/lib/markbridge/renderers/discourse/tags/url_tag.rb +36 -2
  25. data/lib/markbridge/version.rb +1 -1
  26. data/lib/markbridge.rb +0 -1
  27. metadata +1 -10
  28. data/lib/markbridge/processors/discourse_markdown/code_block_tracker.rb +0 -218
  29. data/lib/markbridge/processors/discourse_markdown/detectors/base.rb +0 -63
  30. data/lib/markbridge/processors/discourse_markdown/detectors/event.rb +0 -64
  31. data/lib/markbridge/processors/discourse_markdown/detectors/mention.rb +0 -57
  32. data/lib/markbridge/processors/discourse_markdown/detectors/poll.rb +0 -52
  33. data/lib/markbridge/processors/discourse_markdown/detectors/upload.rb +0 -98
  34. data/lib/markbridge/processors/discourse_markdown/scanner.rb +0 -194
  35. data/lib/markbridge/processors/discourse_markdown.rb +0 -16
  36. data/lib/markbridge/processors.rb +0 -8
@@ -1,63 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Markbridge
4
- module Processors
5
- module DiscourseMarkdown
6
- module Detectors
7
- # Result of a successful detection
8
- # @attr_reader start_pos [Integer] start position in input
9
- # @attr_reader end_pos [Integer] end position in input (exclusive)
10
- # @attr_reader node [AST::Node] the AST node representing the detected construct
11
- Match = Data.define(:start_pos, :end_pos, :node)
12
-
13
- # Base class for construct detectors.
14
- # Subclasses implement detection logic for specific constructs
15
- # (mentions, polls, events, uploads).
16
- #
17
- # @abstract Subclass and implement {#detect}
18
- class Base
19
- # Attempt to detect a construct at the given position.
20
- #
21
- # @param input [String] the full input string
22
- # @param pos [Integer] current position to check
23
- # @return [Match, nil] match result or nil if no match
24
- def detect(input, pos)
25
- raise NotImplementedError, "#{self.class} must implement #detect"
26
- end
27
-
28
- private
29
-
30
- # Helper to check if position is at a word boundary (for mentions, etc.)
31
- # @param input [String] the input string
32
- # @param pos [Integer] position to check
33
- # @return [Boolean] true if at word boundary
34
- def word_boundary?(input, pos)
35
- return true if pos == 0
36
-
37
- prev_char = input[pos - 1]
38
- !prev_char.match?(/\w/)
39
- end
40
-
41
- WORD_PATTERN = /\A[\w\-]*/
42
- private_constant :WORD_PATTERN
43
-
44
- # Helper to extract a word starting at position. Caller must ensure
45
- # pos is within input bounds (`pos <= input.length`).
46
- # @param input [String] the input string
47
- # @param pos [Integer] starting position
48
- # @return [String] the word (may be empty)
49
- def extract_word(input, pos)
50
- input[pos..].match(WORD_PATTERN)[0]
51
- end
52
-
53
- # Parse key="value" or key='value' attribute pairs from a string.
54
- # @param attr_string [String] the attribute string to parse
55
- # @return [Hash<String, String>] parsed attributes with downcased keys
56
- def parse_attributes(attr_string)
57
- attr_string.scan(/(\w+)=["']([^"']*)["']/).to_h.transform_keys(&:downcase)
58
- end
59
- end
60
- end
61
- end
62
- end
63
- end
@@ -1,64 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Markbridge
4
- module Processors
5
- module DiscourseMarkdown
6
- module Detectors
7
- # Detects Discourse event blocks [event]...[/event].
8
- #
9
- # @example
10
- # detector = Event.new
11
- # input = '[event name="Meeting" start="2025-12-15 14:00"][/event]'
12
- # match = detector.detect(input, 0)
13
- # match.node.name # => "Meeting"
14
- class Event < Base
15
- OPEN_TAG_PATTERN = /\[event(?<attrs>[^\]]*)\]/i
16
- CLOSE_TAG_PATTERN = %r{\[/event\]}i
17
-
18
- # Attempt to detect an event at the given position.
19
- #
20
- # @param input [String] the full input string
21
- # @param pos [Integer] current position to check
22
- # @return [Match, nil] match result or nil if no match
23
- def detect(input, pos)
24
- remaining = input[pos..]
25
- open_match = OPEN_TAG_PATTERN.match(remaining)
26
- return nil unless open_match&.begin(0)&.zero?
27
-
28
- # Find closing tag. The opening tag pattern forbids `]` between
29
- # `[event` and its closing `]`, so `[/event]` cannot appear inside
30
- # the opening tag - no need to skip past it.
31
- close_match = CLOSE_TAG_PATTERN.match(remaining)
32
- return nil unless close_match
33
-
34
- # Extract raw content
35
- end_pos = pos + close_match.end(0)
36
- raw = input[pos...end_pos]
37
-
38
- # Parse attributes from opening tag
39
- attrs = parse_attributes(open_match[:attrs])
40
-
41
- # Validate required attributes
42
- name = attrs["name"]
43
- starts_at = attrs["start"]
44
- return nil if name.nil? || starts_at.nil?
45
-
46
- node =
47
- AST::Event.new(
48
- name:,
49
- starts_at:,
50
- ends_at: attrs["end"],
51
- status: attrs["status"],
52
- timezone: attrs["timezone"],
53
- raw:,
54
- )
55
-
56
- Match.new(start_pos: pos, end_pos:, node:)
57
- end
58
-
59
- private
60
- end
61
- end
62
- end
63
- end
64
- end
@@ -1,57 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Markbridge
4
- module Processors
5
- module DiscourseMarkdown
6
- module Detectors
7
- # Detects user and group mentions (@username, @groupname).
8
- #
9
- # @example Basic usage
10
- # detector = Mention.new
11
- # match = detector.detect("Hello @gerhard!", 6)
12
- # match.node.name # => "gerhard"
13
- # match.node.type # => :user (default)
14
- #
15
- # @example With type resolver
16
- # resolver = ->(name) { name == "Testers" ? :group : :user }
17
- # detector = Mention.new(type_resolver: resolver)
18
- # match = detector.detect("@Testers", 0)
19
- # match.node.type # => :group
20
- class Mention < Base
21
- # @param type_resolver [#call, nil] callable that takes a name and returns :user or :group
22
- def initialize(type_resolver: nil)
23
- @type_resolver = type_resolver
24
- end
25
-
26
- # Attempt to detect a mention at the given position.
27
- #
28
- # @param input [String] the full input string
29
- # @param pos [Integer] current position to check
30
- # @return [Match, nil] match result or nil if no match
31
- def detect(input, pos)
32
- return nil unless input[pos] == "@"
33
- return nil unless word_boundary?(input, pos)
34
-
35
- # Extract the username/group name
36
- name = extract_word(input, pos + 1)
37
- return nil if name.empty?
38
-
39
- end_pos = pos + 1 + name.length
40
- type = resolve_type(name)
41
- node = AST::Mention.new(name:, type:)
42
-
43
- Match.new(start_pos: pos, end_pos:, node:)
44
- end
45
-
46
- private
47
-
48
- def resolve_type(name)
49
- return :user unless @type_resolver
50
-
51
- @type_resolver.call(name) || :user
52
- end
53
- end
54
- end
55
- end
56
- end
57
- end
@@ -1,52 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Markbridge
4
- module Processors
5
- module DiscourseMarkdown
6
- module Detectors
7
- # Detects Discourse poll blocks [poll]...[/poll].
8
- #
9
- # @example
10
- # detector = Poll.new
11
- # input = "[poll type=\"regular\"]\n* A\n* B\n[/poll]"
12
- # match = detector.detect(input, 0)
13
- # match.node.type # => "regular"
14
- class Poll < Base
15
- TAG_PATTERN = %r{\A\[poll(?<attrs>[^\]]*)\](?<content>.*?)\[/poll\]}im
16
-
17
- # Attempt to detect a poll at the given position.
18
- #
19
- # @param input [String] the full input string
20
- # @param pos [Integer] current position to check
21
- # @return [Match, nil] match result or nil if no match
22
- def detect(input, pos)
23
- match = TAG_PATTERN.match(input[pos..])
24
- return nil unless match
25
-
26
- attrs = parse_attributes(match[:attrs])
27
- node =
28
- AST::Poll.new(
29
- name: attrs["name"] || "poll",
30
- type: attrs["type"],
31
- results: attrs["results"],
32
- public: attrs["public"] == "true",
33
- chart_type: attrs["charttype"],
34
- options: extract_options(match[:content]),
35
- raw: match[0],
36
- )
37
-
38
- Match.new(start_pos: pos, end_pos: pos + match.end(0), node:)
39
- end
40
-
41
- private
42
-
43
- OPTION_PATTERN = /\A\s*(?:\*\s|-\s|\d+\.\s+)(?<value>.+?)\s*\z/
44
-
45
- def extract_options(content)
46
- content.each_line.filter_map { |line| OPTION_PATTERN.match(line)&.[](:value) }
47
- end
48
- end
49
- end
50
- end
51
- end
52
- end
@@ -1,98 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Markbridge
4
- module Processors
5
- module DiscourseMarkdown
6
- module Detectors
7
- # Detects Discourse upload references using upload:// URLs.
8
- #
9
- # Supports two formats:
10
- # - Images: ![alt|dimensions](upload://sha1.ext)
11
- # - Attachments: [filename|attachment](upload://sha1.ext) (size)
12
- #
13
- # @example Image
14
- # detector = Upload.new
15
- # input = "![logo|64x64](upload://abc123.png)"
16
- # match = detector.detect(input, 0)
17
- # match.node.type # => :image
18
- #
19
- # @example Attachment
20
- # detector = Upload.new
21
- # input = "[doc.pdf|attachment](upload://xyz789.pdf) (1.2 MB)"
22
- # match = detector.detect(input, 0)
23
- # match.node.type # => :attachment
24
- class Upload < Base
25
- # Image: ![alt|dimensions](upload://sha1.ext)
26
- IMAGE_PATTERN =
27
- %r{\A!\[(?<alt>[^|\]]*)(?:\|(?<dimensions>[^\]]*))?\]\(upload://(?<url>[^)]+)\)}
28
-
29
- # Attachment: [filename|attachment](upload://sha1.ext) (size)
30
- ATTACHMENT_PATTERN =
31
- %r{
32
- \A
33
- \[(?<filename>[^|\]]*)\|attachment\]
34
- \(upload://(?<url>[^)]+)\)
35
- (?:\s*\((?<size>[^)]+)\))?
36
- }xi
37
-
38
- # Attempt to detect an upload at the given position.
39
- #
40
- # @param input [String] the full input string
41
- # @param pos [Integer] current position to check
42
- # @return [Match, nil] match result or nil if no match
43
- def detect(input, pos)
44
- remaining = input[pos..]
45
- case input[pos]
46
- when "!"
47
- detect_image(remaining, pos)
48
- when "["
49
- detect_attachment(remaining, pos)
50
- end
51
- end
52
-
53
- private
54
-
55
- def detect_image(remaining, pos)
56
- match = IMAGE_PATTERN.match(remaining)
57
- return nil unless match
58
-
59
- sha1, filename = parse_upload_url(match[:url])
60
- alt = match[:alt]
61
- alt = nil if alt.empty?
62
-
63
- # `type: :image` is omitted because it is AST::Upload's default -
64
- # passing it explicitly was an equivalent-mutation surface.
65
- node =
66
- AST::Upload.new(sha1:, filename:, alt:, dimensions: match[:dimensions], raw: match[0])
67
-
68
- Match.new(start_pos: pos, end_pos: pos + match[0].length, node:)
69
- end
70
-
71
- def detect_attachment(remaining, pos)
72
- match = ATTACHMENT_PATTERN.match(remaining)
73
- return nil unless match
74
-
75
- sha1, = parse_upload_url(match[:url])
76
-
77
- node =
78
- AST::Upload.new(
79
- sha1:,
80
- filename: match[:filename],
81
- type: :attachment,
82
- size: match[:size],
83
- raw: match[0],
84
- )
85
-
86
- Match.new(start_pos: pos, end_pos: pos + match[0].length, node:)
87
- end
88
-
89
- # URL format: sha1.ext or just sha1. Returns [sha1, filename-or-nil].
90
- def parse_upload_url(url_part)
91
- sha1, _, ext = url_part.partition(".")
92
- [sha1, ext.empty? ? nil : url_part]
93
- end
94
- end
95
- end
96
- end
97
- end
98
- end
@@ -1,194 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Markbridge
4
- module Processors
5
- module DiscourseMarkdown
6
- # Result of scanning Discourse Markdown
7
- # @attr_reader markdown [String] the processed markdown with placeholders
8
- # @attr_reader nodes [Array<AST::Node>] extracted AST nodes in order of appearance
9
- ScanResult = Data.define(:markdown, :nodes)
10
-
11
- # Single-pass scanner for Discourse Markdown that extracts specific constructs
12
- # (mentions, polls, events, uploads) while preserving all other content unchanged.
13
- #
14
- # The scanner respects code blocks (fenced, indented, and inline) and will not
15
- # extract constructs that appear within code.
16
- #
17
- # @example Basic usage
18
- # scanner = Scanner.new
19
- # result = scanner.scan("Hello @gerhard!")
20
- # result.nodes.first # => AST::Mention
21
- #
22
- # @example With custom tag library for rendering
23
- # scanner = Scanner.new(tag_library: my_library)
24
- # result = scanner.scan(input)
25
- # result.markdown # => "Hello <<MENTION:1>>!"
26
- #
27
- # @example With mention type resolver
28
- # scanner = Scanner.new(mention_resolver: ->(name) {
29
- # groups.include?(name) ? :group : :user
30
- # })
31
- # result = scanner.scan("@Testers and @gerhard")
32
- # result.nodes[0].type # => :group
33
- # result.nodes[1].type # => :user
34
- class Scanner
35
- # Default detectors in priority order
36
- DEFAULT_DETECTORS = [
37
- Detectors::Poll,
38
- Detectors::Event,
39
- Detectors::Upload,
40
- Detectors::Mention,
41
- ].freeze
42
-
43
- # Characters that can start a construct (for fast bailout)
44
- TRIGGER_CHARS = Set.new(["@", "[", "!"]).freeze
45
-
46
- # @param detectors [Array<Class>] detector classes to use (instantiated automatically)
47
- # @param tag_library [Renderers::Discourse::TagLibrary, nil] tag library for rendering placeholders
48
- # @param mention_resolver [#call, nil] callable that takes a name and returns :user or :group
49
- def initialize(detectors: DEFAULT_DETECTORS, tag_library: nil, mention_resolver: nil)
50
- @detector_instances = build_detectors(detectors, mention_resolver)
51
- @tag_library = tag_library
52
- # @code_tracker / @result / @nodes / @node_index / @pos / @input /
53
- # @line_start are set by #scan before use; no defensive init needed.
54
- end
55
-
56
- # Scan input and extract constructs.
57
- #
58
- # @param input [String] Discourse Markdown input
59
- # @return [ScanResult] result containing processed markdown and extracted nodes
60
- def scan(input)
61
- @code_tracker = CodeBlockTracker.new
62
- @result = +""
63
- @nodes = []
64
- @node_index = 0
65
- @pos = 0
66
- @input = input.to_s
67
- @line_start = true
68
-
69
- scan_input
70
-
71
- ScanResult.new(markdown: @result, nodes: @nodes)
72
- end
73
-
74
- private
75
-
76
- def build_detectors(detectors, mention_resolver)
77
- detectors.map do |klass|
78
- if klass == Detectors::Mention
79
- klass.new(type_resolver: mention_resolver)
80
- else
81
- klass.new
82
- end
83
- end
84
- end
85
-
86
- def scan_input
87
- while @pos < @input.length
88
- # Check for fenced code block boundary at line start
89
- if @line_start
90
- next if advance_code_boundary(:check_fenced_boundary)
91
- next if advance_code_boundary(:check_indented_boundary)
92
- end
93
-
94
- # Check for inline code boundary. check_inline_boundary's
95
- # own fenced/indented guard means we don't need to pre-check
96
- # here — it'll just return nil in those cases.
97
- if @input[@pos] == "`"
98
- new_pos = @code_tracker.check_inline_boundary(@input, @pos)
99
- if new_pos
100
- @result << @input[@pos...new_pos]
101
- @pos = new_pos
102
- @line_start = false
103
- next
104
- end
105
- end
106
-
107
- # If in code, pass through unchanged
108
- if @code_tracker.in_code?
109
- @result << @input[@pos]
110
- @line_start = @input[@pos] == "\n"
111
- @pos += 1
112
- next
113
- end
114
-
115
- # Fast path: only try detectors if current char could start a construct
116
- char = @input[@pos]
117
- if TRIGGER_CHARS.include?(char)
118
- match = detect_at_position
119
- if match
120
- handle_match(match)
121
- next
122
- end
123
- end
124
-
125
- @result << char
126
- @line_start = char == "\n"
127
- @pos += 1
128
- end
129
- end
130
-
131
- def advance_code_boundary(method)
132
- new_pos = @code_tracker.public_send(method, @input, @pos, line_start: true)
133
- return false unless new_pos
134
-
135
- # check_fenced_boundary / check_indented_boundary always stop
136
- # at pos_after_line, which is either after a "\n" or at EOF.
137
- # After-newline → @line_start should be true; at EOF the
138
- # outer `while @pos < @input.length` exits and @line_start
139
- # is unobservable. Setting true unconditionally drops the
140
- # `@input[new_pos - 1] == "\n"` dance.
141
- @result << @input[@pos...new_pos]
142
- @pos = new_pos
143
- @line_start = true
144
- true
145
- end
146
-
147
- def detect_at_position
148
- @detector_instances.each do |detector|
149
- match = detector.detect(@input, @pos)
150
- return match if match
151
- end
152
- nil
153
- end
154
-
155
- def handle_match(match)
156
- node = match.node
157
- @nodes << node
158
- @result << render_placeholder(node)
159
-
160
- # Every detector shipped today matches content that ends on a
161
- # non-newline byte (`]`, `)`, `_`, alphanumeric), so @line_start
162
- # is always false after a successful match. If a future custom
163
- # detector produces a match whose end_pos sits right after
164
- # "\n", re-introduce the `@input[@pos - 1] == "\n"` check.
165
- @pos = match.end_pos
166
- @line_start = false
167
- @node_index += 1
168
- end
169
-
170
- def render_placeholder(node)
171
- if @tag_library
172
- tag = @tag_library[node.class]
173
- return tag.render(node, nil) if tag
174
- end
175
-
176
- default_placeholder(node)
177
- end
178
-
179
- def default_placeholder(node)
180
- case node
181
- when AST::Mention
182
- "<<MENTION:#{@node_index}:#{node.name}>>"
183
- when AST::Poll
184
- "<<POLL:#{@node_index}:#{node.name}>>"
185
- when AST::Event
186
- "<<EVENT:#{@node_index}:#{node.name}>>"
187
- when AST::Upload
188
- "<<UPLOAD:#{@node_index}:#{node.sha1}>>"
189
- end
190
- end
191
- end
192
- end
193
- end
194
- end
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "discourse_markdown/code_block_tracker"
4
- require_relative "discourse_markdown/detectors/base"
5
- require_relative "discourse_markdown/detectors/mention"
6
- require_relative "discourse_markdown/detectors/poll"
7
- require_relative "discourse_markdown/detectors/event"
8
- require_relative "discourse_markdown/detectors/upload"
9
- require_relative "discourse_markdown/scanner"
10
-
11
- module Markbridge
12
- module Processors
13
- module DiscourseMarkdown
14
- end
15
- end
16
- end
@@ -1,8 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "processors/discourse_markdown"
4
-
5
- module Markbridge
6
- module Processors
7
- end
8
- end