markbridge 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/LICENSE.txt +21 -0
- data/lib/markbridge/all.rb +9 -0
- data/lib/markbridge/ast/align.rb +24 -0
- data/lib/markbridge/ast/attachment.rb +42 -0
- data/lib/markbridge/ast/bold.rb +13 -0
- data/lib/markbridge/ast/code.rb +27 -0
- data/lib/markbridge/ast/color.rb +25 -0
- data/lib/markbridge/ast/document.rb +27 -0
- data/lib/markbridge/ast/element.rb +47 -0
- data/lib/markbridge/ast/email.rb +27 -0
- data/lib/markbridge/ast/event.rb +59 -0
- data/lib/markbridge/ast/heading.rb +23 -0
- data/lib/markbridge/ast/horizontal_rule.rb +12 -0
- data/lib/markbridge/ast/image.rb +35 -0
- data/lib/markbridge/ast/italic.rb +13 -0
- data/lib/markbridge/ast/line_break.rb +12 -0
- data/lib/markbridge/ast/list.rb +52 -0
- data/lib/markbridge/ast/list_item.rb +13 -0
- data/lib/markbridge/ast/markdown_text.rb +37 -0
- data/lib/markbridge/ast/mention.rb +29 -0
- data/lib/markbridge/ast/node.rb +19 -0
- data/lib/markbridge/ast/paragraph.rb +13 -0
- data/lib/markbridge/ast/poll.rb +74 -0
- data/lib/markbridge/ast/quote.rb +46 -0
- data/lib/markbridge/ast/size.rb +25 -0
- data/lib/markbridge/ast/spoiler.rb +27 -0
- data/lib/markbridge/ast/strikethrough.rb +13 -0
- data/lib/markbridge/ast/subscript.rb +13 -0
- data/lib/markbridge/ast/superscript.rb +13 -0
- data/lib/markbridge/ast/text.rb +38 -0
- data/lib/markbridge/ast/underline.rb +13 -0
- data/lib/markbridge/ast/upload.rb +74 -0
- data/lib/markbridge/ast/url.rb +27 -0
- data/lib/markbridge/ast.rb +42 -0
- data/lib/markbridge/configuration.rb +11 -0
- data/lib/markbridge/gem_loader.rb +23 -0
- data/lib/markbridge/parsers/bbcode/closing_strategies/base.rb +37 -0
- data/lib/markbridge/parsers/bbcode/closing_strategies/reordering.rb +17 -0
- data/lib/markbridge/parsers/bbcode/closing_strategies/strict.rb +12 -0
- data/lib/markbridge/parsers/bbcode/closing_strategies/tag_reconciler.rb +121 -0
- data/lib/markbridge/parsers/bbcode/errors/max_depth_exceeded_error.rb +13 -0
- data/lib/markbridge/parsers/bbcode/handler_registry.rb +160 -0
- data/lib/markbridge/parsers/bbcode/handlers/align_handler.rb +26 -0
- data/lib/markbridge/parsers/bbcode/handlers/attachment_handler.rb +104 -0
- data/lib/markbridge/parsers/bbcode/handlers/base_handler.rb +44 -0
- data/lib/markbridge/parsers/bbcode/handlers/code_handler.rb +25 -0
- data/lib/markbridge/parsers/bbcode/handlers/color_handler.rb +31 -0
- data/lib/markbridge/parsers/bbcode/handlers/email_handler.rb +25 -0
- data/lib/markbridge/parsers/bbcode/handlers/image_handler.rb +51 -0
- data/lib/markbridge/parsers/bbcode/handlers/list_handler.rb +36 -0
- data/lib/markbridge/parsers/bbcode/handlers/list_item_handler.rb +26 -0
- data/lib/markbridge/parsers/bbcode/handlers/quote_handler.rb +64 -0
- data/lib/markbridge/parsers/bbcode/handlers/raw_handler.rb +48 -0
- data/lib/markbridge/parsers/bbcode/handlers/self_closing_handler.rb +28 -0
- data/lib/markbridge/parsers/bbcode/handlers/simple_handler.rb +28 -0
- data/lib/markbridge/parsers/bbcode/handlers/size_handler.rb +31 -0
- data/lib/markbridge/parsers/bbcode/handlers/spoiler_handler.rb +28 -0
- data/lib/markbridge/parsers/bbcode/handlers/url_handler.rb +24 -0
- data/lib/markbridge/parsers/bbcode/parser.rb +123 -0
- data/lib/markbridge/parsers/bbcode/parser_state.rb +93 -0
- data/lib/markbridge/parsers/bbcode/peekable_enumerator.rb +126 -0
- data/lib/markbridge/parsers/bbcode/raw_content_collector.rb +35 -0
- data/lib/markbridge/parsers/bbcode/raw_content_result.rb +25 -0
- data/lib/markbridge/parsers/bbcode/scanner.rb +231 -0
- data/lib/markbridge/parsers/bbcode/tokens/tag_end_token.rb +21 -0
- data/lib/markbridge/parsers/bbcode/tokens/tag_start_token.rb +23 -0
- data/lib/markbridge/parsers/bbcode/tokens/text_token.rb +23 -0
- data/lib/markbridge/parsers/bbcode/tokens/token.rb +16 -0
- data/lib/markbridge/parsers/bbcode.rb +56 -0
- data/lib/markbridge/parsers/html/handler_registry.rb +87 -0
- data/lib/markbridge/parsers/html/handlers/base_handler.rb +27 -0
- data/lib/markbridge/parsers/html/handlers/image_handler.rb +40 -0
- data/lib/markbridge/parsers/html/handlers/list_handler.rb +29 -0
- data/lib/markbridge/parsers/html/handlers/list_item_handler.rb +26 -0
- data/lib/markbridge/parsers/html/handlers/paragraph_handler.rb +17 -0
- data/lib/markbridge/parsers/html/handlers/quote_handler.rb +28 -0
- data/lib/markbridge/parsers/html/handlers/raw_handler.rb +33 -0
- data/lib/markbridge/parsers/html/handlers/simple_handler.rb +26 -0
- data/lib/markbridge/parsers/html/handlers/url_handler.rb +27 -0
- data/lib/markbridge/parsers/html/parser.rb +113 -0
- data/lib/markbridge/parsers/html.rb +30 -0
- data/lib/markbridge/parsers/media_wiki/inline_parser.rb +332 -0
- data/lib/markbridge/parsers/media_wiki/parser.rb +279 -0
- data/lib/markbridge/parsers/media_wiki.rb +15 -0
- data/lib/markbridge/parsers/text_formatter/handler_registry.rb +130 -0
- data/lib/markbridge/parsers/text_formatter/handlers/attachment_handler.rb +33 -0
- data/lib/markbridge/parsers/text_formatter/handlers/attribute_handler.rb +40 -0
- data/lib/markbridge/parsers/text_formatter/handlers/base_handler.rb +45 -0
- data/lib/markbridge/parsers/text_formatter/handlers/code_handler.rb +28 -0
- data/lib/markbridge/parsers/text_formatter/handlers/email_handler.rb +27 -0
- data/lib/markbridge/parsers/text_formatter/handlers/image_handler.rb +32 -0
- data/lib/markbridge/parsers/text_formatter/handlers/list_handler.rb +31 -0
- data/lib/markbridge/parsers/text_formatter/handlers/quote_handler.rb +33 -0
- data/lib/markbridge/parsers/text_formatter/handlers/simple_handler.rb +37 -0
- data/lib/markbridge/parsers/text_formatter/handlers/url_handler.rb +29 -0
- data/lib/markbridge/parsers/text_formatter/parser.rb +132 -0
- data/lib/markbridge/parsers/text_formatter.rb +31 -0
- data/lib/markbridge/processors/discourse_markdown/code_block_tracker.rb +199 -0
- data/lib/markbridge/processors/discourse_markdown/detectors/base.rb +57 -0
- data/lib/markbridge/processors/discourse_markdown/detectors/event.rb +73 -0
- data/lib/markbridge/processors/discourse_markdown/detectors/mention.rb +57 -0
- data/lib/markbridge/processors/discourse_markdown/detectors/poll.rb +90 -0
- data/lib/markbridge/processors/discourse_markdown/detectors/upload.rb +123 -0
- data/lib/markbridge/processors/discourse_markdown/scanner.rb +199 -0
- data/lib/markbridge/processors/discourse_markdown.rb +16 -0
- data/lib/markbridge/processors.rb +8 -0
- data/lib/markbridge/renderers/discourse/builders/list_item_builder.rb +83 -0
- data/lib/markbridge/renderers/discourse/markdown_escaper.rb +468 -0
- data/lib/markbridge/renderers/discourse/render_context.rb +80 -0
- data/lib/markbridge/renderers/discourse/renderer.rb +63 -0
- data/lib/markbridge/renderers/discourse/rendering_interface.rb +86 -0
- data/lib/markbridge/renderers/discourse/tag.rb +29 -0
- data/lib/markbridge/renderers/discourse/tag_library.rb +67 -0
- data/lib/markbridge/renderers/discourse/tags/align_tag.rb +24 -0
- data/lib/markbridge/renderers/discourse/tags/attachment_tag.rb +46 -0
- data/lib/markbridge/renderers/discourse/tags/bold_tag.rb +18 -0
- data/lib/markbridge/renderers/discourse/tags/code_tag.rb +54 -0
- data/lib/markbridge/renderers/discourse/tags/color_tag.rb +27 -0
- data/lib/markbridge/renderers/discourse/tags/email_tag.rb +24 -0
- data/lib/markbridge/renderers/discourse/tags/event_tag.rb +49 -0
- data/lib/markbridge/renderers/discourse/tags/heading_tag.rb +21 -0
- data/lib/markbridge/renderers/discourse/tags/horizontal_rule_tag.rb +16 -0
- data/lib/markbridge/renderers/discourse/tags/image_tag.rb +29 -0
- data/lib/markbridge/renderers/discourse/tags/italic_tag.rb +18 -0
- data/lib/markbridge/renderers/discourse/tags/line_break_tag.rb +16 -0
- data/lib/markbridge/renderers/discourse/tags/list_item_tag.rb +87 -0
- data/lib/markbridge/renderers/discourse/tags/list_tag.rb +39 -0
- data/lib/markbridge/renderers/discourse/tags/mention_tag.rb +34 -0
- data/lib/markbridge/renderers/discourse/tags/paragraph_tag.rb +21 -0
- data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +51 -0
- data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +32 -0
- data/lib/markbridge/renderers/discourse/tags/size_tag.rb +27 -0
- data/lib/markbridge/renderers/discourse/tags/spoiler_tag.rb +24 -0
- data/lib/markbridge/renderers/discourse/tags/strikethrough_tag.rb +18 -0
- data/lib/markbridge/renderers/discourse/tags/subscript_tag.rb +19 -0
- data/lib/markbridge/renderers/discourse/tags/superscript_tag.rb +19 -0
- data/lib/markbridge/renderers/discourse/tags/underline_tag.rb +19 -0
- data/lib/markbridge/renderers/discourse/tags/upload_tag.rb +80 -0
- data/lib/markbridge/renderers/discourse/tags/url_tag.rb +24 -0
- data/lib/markbridge/renderers/discourse.rb +50 -0
- data/lib/markbridge/version.rb +5 -0
- data/lib/markbridge.rb +201 -0
- metadata +186 -0
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
OPEN_TAG_PATTERN = /\[poll([^\]]*)\]/i
|
|
16
|
+
CLOSE_TAG_PATTERN = %r{\[/poll\]}i
|
|
17
|
+
|
|
18
|
+
# Attempt to detect a poll 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
|
+
return nil unless input[pos] == "["
|
|
25
|
+
|
|
26
|
+
# Check for opening tag
|
|
27
|
+
remaining = input[pos..]
|
|
28
|
+
open_match = OPEN_TAG_PATTERN.match(remaining)
|
|
29
|
+
return nil unless open_match&.begin(0)&.zero?
|
|
30
|
+
|
|
31
|
+
# Find closing tag
|
|
32
|
+
close_match = CLOSE_TAG_PATTERN.match(remaining, open_match.end(0))
|
|
33
|
+
return nil unless close_match
|
|
34
|
+
|
|
35
|
+
# Extract raw content
|
|
36
|
+
end_pos = pos + close_match.end(0)
|
|
37
|
+
raw = input[pos...end_pos]
|
|
38
|
+
|
|
39
|
+
# Parse attributes from opening tag
|
|
40
|
+
attrs = parse_attributes(open_match[1])
|
|
41
|
+
|
|
42
|
+
# Extract options from content between tags
|
|
43
|
+
content = remaining[open_match.end(0)...close_match.begin(0)]
|
|
44
|
+
options = extract_options(content)
|
|
45
|
+
|
|
46
|
+
node =
|
|
47
|
+
AST::Poll.new(
|
|
48
|
+
name: attrs["name"] || "poll",
|
|
49
|
+
type: attrs["type"],
|
|
50
|
+
results: attrs["results"],
|
|
51
|
+
public: attrs["public"] == "true",
|
|
52
|
+
chart_type: attrs["charttype"] || attrs["chartType"],
|
|
53
|
+
options:,
|
|
54
|
+
raw:,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
Match.new(start_pos: pos, end_pos:, node:)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def parse_attributes(attr_string)
|
|
63
|
+
attrs = {}
|
|
64
|
+
return attrs if attr_string.nil? || attr_string.empty?
|
|
65
|
+
|
|
66
|
+
# Match key="value" or key='value' patterns
|
|
67
|
+
attr_string.scan(/(\w+)=["']([^"']*)["']/) { |key, value| attrs[key.downcase] = value }
|
|
68
|
+
|
|
69
|
+
attrs
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def extract_options(content)
|
|
73
|
+
options = []
|
|
74
|
+
content.each_line do |line|
|
|
75
|
+
line = line.strip
|
|
76
|
+
if line.start_with?("* ")
|
|
77
|
+
options << line[2..].strip
|
|
78
|
+
elsif line.start_with?("- ")
|
|
79
|
+
options << line[2..].strip
|
|
80
|
+
elsif line.match?(/^\d+\.\s/)
|
|
81
|
+
options << line.sub(/^\d+\.\s*/, "").strip
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
options
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
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: 
|
|
11
|
+
# - Attachments: [filename|attachment](upload://sha1.ext) (size)
|
|
12
|
+
#
|
|
13
|
+
# @example Image
|
|
14
|
+
# detector = Upload.new
|
|
15
|
+
# input = ""
|
|
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
|
+
# Pattern for image: 
|
|
26
|
+
IMAGE_PATTERN = %r{!\[([^\]]*)\]\(upload://([^)]+)\)}
|
|
27
|
+
|
|
28
|
+
# Pattern for attachment: [filename|attachment](upload://sha1.ext) followed by optional (size)
|
|
29
|
+
ATTACHMENT_PATTERN = %r{\[([^\]]*\|attachment)\]\(upload://([^)]+)\)(\s*\([^)]+\))?}
|
|
30
|
+
|
|
31
|
+
# Attempt to detect an upload at the given position.
|
|
32
|
+
#
|
|
33
|
+
# @param input [String] the full input string
|
|
34
|
+
# @param pos [Integer] current position to check
|
|
35
|
+
# @return [Match, nil] match result or nil if no match
|
|
36
|
+
def detect(input, pos)
|
|
37
|
+
char = input[pos]
|
|
38
|
+
return nil unless char == "!" || char == "["
|
|
39
|
+
|
|
40
|
+
remaining = input[pos..]
|
|
41
|
+
|
|
42
|
+
if char == "!"
|
|
43
|
+
detect_image(remaining, pos)
|
|
44
|
+
else
|
|
45
|
+
detect_attachment(remaining, pos)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def detect_image(remaining, pos)
|
|
52
|
+
match = IMAGE_PATTERN.match(remaining)
|
|
53
|
+
return nil unless match&.begin(0)&.zero?
|
|
54
|
+
|
|
55
|
+
raw = match[0]
|
|
56
|
+
alt_part = match[1]
|
|
57
|
+
url_part = match[2]
|
|
58
|
+
|
|
59
|
+
# Parse alt and dimensions from "alt|dimensions" format
|
|
60
|
+
alt, dimensions = parse_alt_dimensions(alt_part)
|
|
61
|
+
|
|
62
|
+
# Extract SHA1 and filename from URL
|
|
63
|
+
sha1, filename = parse_upload_url(url_part)
|
|
64
|
+
|
|
65
|
+
node = AST::Upload.new(sha1:, filename:, type: :image, alt:, dimensions:, raw:)
|
|
66
|
+
|
|
67
|
+
Match.new(start_pos: pos, end_pos: pos + raw.length, node:)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def detect_attachment(remaining, pos)
|
|
71
|
+
match = ATTACHMENT_PATTERN.match(remaining)
|
|
72
|
+
return nil unless match&.begin(0)&.zero?
|
|
73
|
+
|
|
74
|
+
raw = match[0]
|
|
75
|
+
name_part = match[1]
|
|
76
|
+
url_part = match[2]
|
|
77
|
+
size_part = match[3]
|
|
78
|
+
|
|
79
|
+
# Parse filename from "filename|attachment" format
|
|
80
|
+
filename = name_part.sub(/\|attachment$/i, "")
|
|
81
|
+
|
|
82
|
+
# Extract SHA1 from URL
|
|
83
|
+
sha1, _url_filename = parse_upload_url(url_part)
|
|
84
|
+
|
|
85
|
+
# Parse size if present
|
|
86
|
+
size = size_part&.strip&.delete_prefix("(")&.delete_suffix(")")
|
|
87
|
+
|
|
88
|
+
node = AST::Upload.new(sha1:, filename:, type: :attachment, size:, raw:)
|
|
89
|
+
|
|
90
|
+
Match.new(start_pos: pos, end_pos: pos + raw.length, node:)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def parse_alt_dimensions(alt_part)
|
|
94
|
+
return nil, nil if alt_part.nil? || alt_part.empty?
|
|
95
|
+
|
|
96
|
+
if alt_part.include?("|")
|
|
97
|
+
parts = alt_part.split("|", 2)
|
|
98
|
+
alt = parts[0].empty? ? nil : parts[0]
|
|
99
|
+
dimensions = parts[1]
|
|
100
|
+
[alt, dimensions]
|
|
101
|
+
else
|
|
102
|
+
[alt_part, nil]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def parse_upload_url(url_part)
|
|
107
|
+
# URL format: sha1.ext or just sha1
|
|
108
|
+
if url_part.include?(".")
|
|
109
|
+
parts = url_part.split(".", 2)
|
|
110
|
+
sha1 = parts[0]
|
|
111
|
+
filename = url_part
|
|
112
|
+
else
|
|
113
|
+
sha1 = url_part
|
|
114
|
+
filename = nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
[sha1, filename]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
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
|
+
end
|
|
53
|
+
|
|
54
|
+
# Scan input and extract constructs.
|
|
55
|
+
#
|
|
56
|
+
# @param input [String] Discourse Markdown input
|
|
57
|
+
# @return [ScanResult] result containing processed markdown and extracted nodes
|
|
58
|
+
def scan(input)
|
|
59
|
+
return ScanResult.new(markdown: "", nodes: []) if input.nil? || input.empty?
|
|
60
|
+
|
|
61
|
+
@code_tracker = CodeBlockTracker.new
|
|
62
|
+
@result = +""
|
|
63
|
+
@nodes = []
|
|
64
|
+
@node_index = 0
|
|
65
|
+
@pos = 0
|
|
66
|
+
@input = input
|
|
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.is_a?(Class)
|
|
79
|
+
if klass == Detectors::Mention && mention_resolver
|
|
80
|
+
klass.new(type_resolver: mention_resolver)
|
|
81
|
+
else
|
|
82
|
+
klass.new
|
|
83
|
+
end
|
|
84
|
+
else
|
|
85
|
+
klass
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def scan_input
|
|
91
|
+
while @pos < @input.length
|
|
92
|
+
# Check for fenced code block boundary at line start
|
|
93
|
+
if @line_start
|
|
94
|
+
new_pos = @code_tracker.check_fenced_boundary(@input, @pos, line_start: true)
|
|
95
|
+
if new_pos
|
|
96
|
+
@result << @input[@pos...new_pos]
|
|
97
|
+
@pos = new_pos
|
|
98
|
+
@line_start = new_pos > 0 && @input[new_pos - 1] == "\n"
|
|
99
|
+
next
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Check for indented code block (4+ spaces or tab)
|
|
103
|
+
new_pos = @code_tracker.check_indented_boundary(@input, @pos, line_start: true)
|
|
104
|
+
if new_pos
|
|
105
|
+
@result << @input[@pos...new_pos]
|
|
106
|
+
@pos = new_pos
|
|
107
|
+
@line_start = new_pos > 0 && @input[new_pos - 1] == "\n"
|
|
108
|
+
next
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Check for inline code boundary
|
|
113
|
+
if @input[@pos] == "`" && !@code_tracker.in_fenced_block &&
|
|
114
|
+
!@code_tracker.in_indented_block
|
|
115
|
+
new_pos = @code_tracker.check_inline_boundary(@input, @pos)
|
|
116
|
+
if new_pos
|
|
117
|
+
@result << @input[@pos...new_pos]
|
|
118
|
+
@pos = new_pos
|
|
119
|
+
@line_start = false
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# If in code, pass through unchanged
|
|
125
|
+
if @code_tracker.in_code?
|
|
126
|
+
@result << @input[@pos]
|
|
127
|
+
@line_start = @input[@pos] == "\n"
|
|
128
|
+
@pos += 1
|
|
129
|
+
next
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Fast path: only try detectors if current char could start a construct
|
|
133
|
+
char = @input[@pos]
|
|
134
|
+
if TRIGGER_CHARS.include?(char)
|
|
135
|
+
match = detect_at_position
|
|
136
|
+
if match
|
|
137
|
+
handle_match(match)
|
|
138
|
+
next
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
@result << char
|
|
143
|
+
@line_start = char == "\n"
|
|
144
|
+
@pos += 1
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def detect_at_position
|
|
149
|
+
@detector_instances.each do |detector|
|
|
150
|
+
match = detector.detect(@input, @pos)
|
|
151
|
+
return match if match
|
|
152
|
+
end
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def handle_match(match)
|
|
157
|
+
node = match.node
|
|
158
|
+
@nodes << node
|
|
159
|
+
|
|
160
|
+
# Render placeholder using tag library if available
|
|
161
|
+
placeholder = render_placeholder(node)
|
|
162
|
+
@result << placeholder
|
|
163
|
+
|
|
164
|
+
@pos = match.end_pos
|
|
165
|
+
@line_start = @pos > 0 && @input[@pos - 1] == "\n"
|
|
166
|
+
@node_index += 1
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def render_placeholder(node)
|
|
170
|
+
if @tag_library
|
|
171
|
+
tag = @tag_library[node.class]
|
|
172
|
+
if tag
|
|
173
|
+
# Create a minimal interface for rendering
|
|
174
|
+
return tag.render(node, nil)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Default placeholder format if no tag library or tag not found
|
|
179
|
+
default_placeholder(node)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def default_placeholder(node)
|
|
183
|
+
case node
|
|
184
|
+
when AST::Mention
|
|
185
|
+
"<<MENTION:#{@node_index}:#{node.name}>>"
|
|
186
|
+
when AST::Poll
|
|
187
|
+
"<<POLL:#{@node_index}:#{node.name}>>"
|
|
188
|
+
when AST::Event
|
|
189
|
+
"<<EVENT:#{@node_index}:#{node.name}>>"
|
|
190
|
+
when AST::Upload
|
|
191
|
+
"<<UPLOAD:#{@node_index}:#{node.sha1}>>"
|
|
192
|
+
else
|
|
193
|
+
"<<UNKNOWN:#{@node_index}>>"
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
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
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markbridge
|
|
4
|
+
module Renderers
|
|
5
|
+
module Discourse
|
|
6
|
+
module Builders
|
|
7
|
+
# Builder for list item formatting
|
|
8
|
+
# Handles complex multi-line formatting with proper indentation
|
|
9
|
+
# and preservation of blank lines and nested list items
|
|
10
|
+
class ListItemBuilder
|
|
11
|
+
# Build a formatted list item string
|
|
12
|
+
# @param content [String] the item content
|
|
13
|
+
# @param marker [String] the list marker ("- " or "1. ")
|
|
14
|
+
# @param indent [String] the indentation string
|
|
15
|
+
# @return [String]
|
|
16
|
+
def build(content, marker:, indent:)
|
|
17
|
+
lines = content.split("\n")
|
|
18
|
+
lines = [""] if lines.empty? # Handle empty content
|
|
19
|
+
first_line = "#{indent}#{marker}#{lines.first}"
|
|
20
|
+
|
|
21
|
+
return "#{first_line}\n" if lines.size == 1
|
|
22
|
+
|
|
23
|
+
# Handle multi-line content with sophisticated blank line handling
|
|
24
|
+
format_multiline(lines, first_line, indent)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# Format multi-line content with proper indentation
|
|
30
|
+
# @param lines [Array<String>] content lines
|
|
31
|
+
# @param first_line [String] the formatted first line
|
|
32
|
+
# @param indent [String] base indentation
|
|
33
|
+
# @return [String]
|
|
34
|
+
def format_multiline(lines, first_line, indent)
|
|
35
|
+
continuation_indent = "#{indent} "
|
|
36
|
+
continuation_lines = lines[1..]
|
|
37
|
+
|
|
38
|
+
rest =
|
|
39
|
+
continuation_lines.each_with_index.filter_map do |line, idx|
|
|
40
|
+
format_continuation_line(line, idx, continuation_lines, continuation_indent)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
"#{([first_line] + rest).join("\n")}\n"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Format a single continuation line
|
|
47
|
+
# @param line [String] the line to format
|
|
48
|
+
# @param idx [Integer] index in continuation_lines array
|
|
49
|
+
# @param continuation_lines [Array<String>] all continuation lines
|
|
50
|
+
# @param continuation_indent [String] indent for continuation
|
|
51
|
+
# @return [String, nil] formatted line or nil to skip
|
|
52
|
+
def format_continuation_line(line, idx, continuation_lines, continuation_indent)
|
|
53
|
+
# Handle empty lines
|
|
54
|
+
return handle_empty_line(idx, continuation_lines, continuation_indent) if line.empty?
|
|
55
|
+
|
|
56
|
+
# Check if line is already a list item (has indentation + marker)
|
|
57
|
+
if line.match?(/\A\s*(?:-|\d+\.)\s/)
|
|
58
|
+
# Already a list item - don't add extra indentation
|
|
59
|
+
line
|
|
60
|
+
else
|
|
61
|
+
# Regular continuation line - add indentation
|
|
62
|
+
"#{continuation_indent}#{line}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Handle empty lines in continuation
|
|
67
|
+
# @param idx [Integer] index in continuation_lines
|
|
68
|
+
# @param continuation_lines [Array<String>] all continuation lines
|
|
69
|
+
# @param continuation_indent [String] indent for continuation
|
|
70
|
+
# @return [String, nil] formatted line or nil to skip
|
|
71
|
+
def handle_empty_line(idx, continuation_lines, continuation_indent)
|
|
72
|
+
# Skip empty lines that come before nested list items (structural blanks)
|
|
73
|
+
next_line = continuation_lines[idx + 1]
|
|
74
|
+
return nil if next_line&.match?(/\A\s*(?:-|\d+\.)\s/)
|
|
75
|
+
|
|
76
|
+
# Preserve empty lines within text content (paragraph breaks) with indentation
|
|
77
|
+
continuation_indent
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|