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.
Files changed (144) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/lib/markbridge/all.rb +9 -0
  4. data/lib/markbridge/ast/align.rb +24 -0
  5. data/lib/markbridge/ast/attachment.rb +42 -0
  6. data/lib/markbridge/ast/bold.rb +13 -0
  7. data/lib/markbridge/ast/code.rb +27 -0
  8. data/lib/markbridge/ast/color.rb +25 -0
  9. data/lib/markbridge/ast/document.rb +27 -0
  10. data/lib/markbridge/ast/element.rb +47 -0
  11. data/lib/markbridge/ast/email.rb +27 -0
  12. data/lib/markbridge/ast/event.rb +59 -0
  13. data/lib/markbridge/ast/heading.rb +23 -0
  14. data/lib/markbridge/ast/horizontal_rule.rb +12 -0
  15. data/lib/markbridge/ast/image.rb +35 -0
  16. data/lib/markbridge/ast/italic.rb +13 -0
  17. data/lib/markbridge/ast/line_break.rb +12 -0
  18. data/lib/markbridge/ast/list.rb +52 -0
  19. data/lib/markbridge/ast/list_item.rb +13 -0
  20. data/lib/markbridge/ast/markdown_text.rb +37 -0
  21. data/lib/markbridge/ast/mention.rb +29 -0
  22. data/lib/markbridge/ast/node.rb +19 -0
  23. data/lib/markbridge/ast/paragraph.rb +13 -0
  24. data/lib/markbridge/ast/poll.rb +74 -0
  25. data/lib/markbridge/ast/quote.rb +46 -0
  26. data/lib/markbridge/ast/size.rb +25 -0
  27. data/lib/markbridge/ast/spoiler.rb +27 -0
  28. data/lib/markbridge/ast/strikethrough.rb +13 -0
  29. data/lib/markbridge/ast/subscript.rb +13 -0
  30. data/lib/markbridge/ast/superscript.rb +13 -0
  31. data/lib/markbridge/ast/text.rb +38 -0
  32. data/lib/markbridge/ast/underline.rb +13 -0
  33. data/lib/markbridge/ast/upload.rb +74 -0
  34. data/lib/markbridge/ast/url.rb +27 -0
  35. data/lib/markbridge/ast.rb +42 -0
  36. data/lib/markbridge/configuration.rb +11 -0
  37. data/lib/markbridge/gem_loader.rb +23 -0
  38. data/lib/markbridge/parsers/bbcode/closing_strategies/base.rb +37 -0
  39. data/lib/markbridge/parsers/bbcode/closing_strategies/reordering.rb +17 -0
  40. data/lib/markbridge/parsers/bbcode/closing_strategies/strict.rb +12 -0
  41. data/lib/markbridge/parsers/bbcode/closing_strategies/tag_reconciler.rb +121 -0
  42. data/lib/markbridge/parsers/bbcode/errors/max_depth_exceeded_error.rb +13 -0
  43. data/lib/markbridge/parsers/bbcode/handler_registry.rb +160 -0
  44. data/lib/markbridge/parsers/bbcode/handlers/align_handler.rb +26 -0
  45. data/lib/markbridge/parsers/bbcode/handlers/attachment_handler.rb +104 -0
  46. data/lib/markbridge/parsers/bbcode/handlers/base_handler.rb +44 -0
  47. data/lib/markbridge/parsers/bbcode/handlers/code_handler.rb +25 -0
  48. data/lib/markbridge/parsers/bbcode/handlers/color_handler.rb +31 -0
  49. data/lib/markbridge/parsers/bbcode/handlers/email_handler.rb +25 -0
  50. data/lib/markbridge/parsers/bbcode/handlers/image_handler.rb +51 -0
  51. data/lib/markbridge/parsers/bbcode/handlers/list_handler.rb +36 -0
  52. data/lib/markbridge/parsers/bbcode/handlers/list_item_handler.rb +26 -0
  53. data/lib/markbridge/parsers/bbcode/handlers/quote_handler.rb +64 -0
  54. data/lib/markbridge/parsers/bbcode/handlers/raw_handler.rb +48 -0
  55. data/lib/markbridge/parsers/bbcode/handlers/self_closing_handler.rb +28 -0
  56. data/lib/markbridge/parsers/bbcode/handlers/simple_handler.rb +28 -0
  57. data/lib/markbridge/parsers/bbcode/handlers/size_handler.rb +31 -0
  58. data/lib/markbridge/parsers/bbcode/handlers/spoiler_handler.rb +28 -0
  59. data/lib/markbridge/parsers/bbcode/handlers/url_handler.rb +24 -0
  60. data/lib/markbridge/parsers/bbcode/parser.rb +123 -0
  61. data/lib/markbridge/parsers/bbcode/parser_state.rb +93 -0
  62. data/lib/markbridge/parsers/bbcode/peekable_enumerator.rb +126 -0
  63. data/lib/markbridge/parsers/bbcode/raw_content_collector.rb +35 -0
  64. data/lib/markbridge/parsers/bbcode/raw_content_result.rb +25 -0
  65. data/lib/markbridge/parsers/bbcode/scanner.rb +231 -0
  66. data/lib/markbridge/parsers/bbcode/tokens/tag_end_token.rb +21 -0
  67. data/lib/markbridge/parsers/bbcode/tokens/tag_start_token.rb +23 -0
  68. data/lib/markbridge/parsers/bbcode/tokens/text_token.rb +23 -0
  69. data/lib/markbridge/parsers/bbcode/tokens/token.rb +16 -0
  70. data/lib/markbridge/parsers/bbcode.rb +56 -0
  71. data/lib/markbridge/parsers/html/handler_registry.rb +87 -0
  72. data/lib/markbridge/parsers/html/handlers/base_handler.rb +27 -0
  73. data/lib/markbridge/parsers/html/handlers/image_handler.rb +40 -0
  74. data/lib/markbridge/parsers/html/handlers/list_handler.rb +29 -0
  75. data/lib/markbridge/parsers/html/handlers/list_item_handler.rb +26 -0
  76. data/lib/markbridge/parsers/html/handlers/paragraph_handler.rb +17 -0
  77. data/lib/markbridge/parsers/html/handlers/quote_handler.rb +28 -0
  78. data/lib/markbridge/parsers/html/handlers/raw_handler.rb +33 -0
  79. data/lib/markbridge/parsers/html/handlers/simple_handler.rb +26 -0
  80. data/lib/markbridge/parsers/html/handlers/url_handler.rb +27 -0
  81. data/lib/markbridge/parsers/html/parser.rb +113 -0
  82. data/lib/markbridge/parsers/html.rb +30 -0
  83. data/lib/markbridge/parsers/media_wiki/inline_parser.rb +332 -0
  84. data/lib/markbridge/parsers/media_wiki/parser.rb +279 -0
  85. data/lib/markbridge/parsers/media_wiki.rb +15 -0
  86. data/lib/markbridge/parsers/text_formatter/handler_registry.rb +130 -0
  87. data/lib/markbridge/parsers/text_formatter/handlers/attachment_handler.rb +33 -0
  88. data/lib/markbridge/parsers/text_formatter/handlers/attribute_handler.rb +40 -0
  89. data/lib/markbridge/parsers/text_formatter/handlers/base_handler.rb +45 -0
  90. data/lib/markbridge/parsers/text_formatter/handlers/code_handler.rb +28 -0
  91. data/lib/markbridge/parsers/text_formatter/handlers/email_handler.rb +27 -0
  92. data/lib/markbridge/parsers/text_formatter/handlers/image_handler.rb +32 -0
  93. data/lib/markbridge/parsers/text_formatter/handlers/list_handler.rb +31 -0
  94. data/lib/markbridge/parsers/text_formatter/handlers/quote_handler.rb +33 -0
  95. data/lib/markbridge/parsers/text_formatter/handlers/simple_handler.rb +37 -0
  96. data/lib/markbridge/parsers/text_formatter/handlers/url_handler.rb +29 -0
  97. data/lib/markbridge/parsers/text_formatter/parser.rb +132 -0
  98. data/lib/markbridge/parsers/text_formatter.rb +31 -0
  99. data/lib/markbridge/processors/discourse_markdown/code_block_tracker.rb +199 -0
  100. data/lib/markbridge/processors/discourse_markdown/detectors/base.rb +57 -0
  101. data/lib/markbridge/processors/discourse_markdown/detectors/event.rb +73 -0
  102. data/lib/markbridge/processors/discourse_markdown/detectors/mention.rb +57 -0
  103. data/lib/markbridge/processors/discourse_markdown/detectors/poll.rb +90 -0
  104. data/lib/markbridge/processors/discourse_markdown/detectors/upload.rb +123 -0
  105. data/lib/markbridge/processors/discourse_markdown/scanner.rb +199 -0
  106. data/lib/markbridge/processors/discourse_markdown.rb +16 -0
  107. data/lib/markbridge/processors.rb +8 -0
  108. data/lib/markbridge/renderers/discourse/builders/list_item_builder.rb +83 -0
  109. data/lib/markbridge/renderers/discourse/markdown_escaper.rb +468 -0
  110. data/lib/markbridge/renderers/discourse/render_context.rb +80 -0
  111. data/lib/markbridge/renderers/discourse/renderer.rb +63 -0
  112. data/lib/markbridge/renderers/discourse/rendering_interface.rb +86 -0
  113. data/lib/markbridge/renderers/discourse/tag.rb +29 -0
  114. data/lib/markbridge/renderers/discourse/tag_library.rb +67 -0
  115. data/lib/markbridge/renderers/discourse/tags/align_tag.rb +24 -0
  116. data/lib/markbridge/renderers/discourse/tags/attachment_tag.rb +46 -0
  117. data/lib/markbridge/renderers/discourse/tags/bold_tag.rb +18 -0
  118. data/lib/markbridge/renderers/discourse/tags/code_tag.rb +54 -0
  119. data/lib/markbridge/renderers/discourse/tags/color_tag.rb +27 -0
  120. data/lib/markbridge/renderers/discourse/tags/email_tag.rb +24 -0
  121. data/lib/markbridge/renderers/discourse/tags/event_tag.rb +49 -0
  122. data/lib/markbridge/renderers/discourse/tags/heading_tag.rb +21 -0
  123. data/lib/markbridge/renderers/discourse/tags/horizontal_rule_tag.rb +16 -0
  124. data/lib/markbridge/renderers/discourse/tags/image_tag.rb +29 -0
  125. data/lib/markbridge/renderers/discourse/tags/italic_tag.rb +18 -0
  126. data/lib/markbridge/renderers/discourse/tags/line_break_tag.rb +16 -0
  127. data/lib/markbridge/renderers/discourse/tags/list_item_tag.rb +87 -0
  128. data/lib/markbridge/renderers/discourse/tags/list_tag.rb +39 -0
  129. data/lib/markbridge/renderers/discourse/tags/mention_tag.rb +34 -0
  130. data/lib/markbridge/renderers/discourse/tags/paragraph_tag.rb +21 -0
  131. data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +51 -0
  132. data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +32 -0
  133. data/lib/markbridge/renderers/discourse/tags/size_tag.rb +27 -0
  134. data/lib/markbridge/renderers/discourse/tags/spoiler_tag.rb +24 -0
  135. data/lib/markbridge/renderers/discourse/tags/strikethrough_tag.rb +18 -0
  136. data/lib/markbridge/renderers/discourse/tags/subscript_tag.rb +19 -0
  137. data/lib/markbridge/renderers/discourse/tags/superscript_tag.rb +19 -0
  138. data/lib/markbridge/renderers/discourse/tags/underline_tag.rb +19 -0
  139. data/lib/markbridge/renderers/discourse/tags/upload_tag.rb +80 -0
  140. data/lib/markbridge/renderers/discourse/tags/url_tag.rb +24 -0
  141. data/lib/markbridge/renderers/discourse.rb +50 -0
  142. data/lib/markbridge/version.rb +5 -0
  143. data/lib/markbridge.rb +201 -0
  144. metadata +186 -0
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module AST
5
+ # Represents a Discourse poll.
6
+ #
7
+ # @example Basic poll
8
+ # poll = AST::Poll.new(
9
+ # name: "poll",
10
+ # type: "regular",
11
+ # options: ["A", "B", "C"]
12
+ # )
13
+ #
14
+ # @example Poll with all attributes
15
+ # poll = AST::Poll.new(
16
+ # name: "favorite-color",
17
+ # type: "multiple",
18
+ # results: "on_vote",
19
+ # public: true,
20
+ # chart_type: "pie",
21
+ # options: ["Red", "Blue", "Green"],
22
+ # raw: "[poll name=\"favorite-color\"]..."
23
+ # )
24
+ class Poll < Node
25
+ # @return [String] the poll name/identifier
26
+ attr_reader :name
27
+
28
+ # @return [String, nil] poll type (regular, multiple, number)
29
+ attr_reader :type
30
+
31
+ # @return [String, nil] when to show results (always, on_vote, on_close, staff_only)
32
+ attr_reader :results
33
+
34
+ # @return [Boolean] whether votes are public
35
+ attr_reader :public
36
+
37
+ # @return [String, nil] chart type (bar, pie)
38
+ attr_reader :chart_type
39
+
40
+ # @return [Array<String>] poll options
41
+ attr_reader :options
42
+
43
+ # @return [String, nil] the original raw BBCode
44
+ attr_reader :raw
45
+
46
+ # Create a new Poll node.
47
+ #
48
+ # @param name [String] the poll name/identifier
49
+ # @param type [String, nil] poll type
50
+ # @param results [String, nil] when to show results
51
+ # @param public [Boolean] whether votes are public
52
+ # @param chart_type [String, nil] chart type
53
+ # @param options [Array<String>] poll options
54
+ # @param raw [String, nil] the original raw BBCode
55
+ def initialize(
56
+ name: "poll",
57
+ type: nil,
58
+ results: nil,
59
+ public: false,
60
+ chart_type: nil,
61
+ options: [],
62
+ raw: nil
63
+ )
64
+ @name = name
65
+ @type = type
66
+ @results = results
67
+ @public = public
68
+ @chart_type = chart_type
69
+ @options = options
70
+ @raw = raw
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module AST
5
+ # Represents a quote/blockquote element.
6
+ #
7
+ # @example Basic quote
8
+ # quote = AST::Quote.new
9
+ # quote << AST::Text.new("quoted text")
10
+ #
11
+ # @example Quote with author attribution
12
+ # quote = AST::Quote.new(author: "John")
13
+ # quote << AST::Text.new("quoted text")
14
+ #
15
+ # @example Quote with full Discourse context
16
+ # quote = AST::Quote.new(author: "John", post: "123", topic: "456", username: "john123")
17
+ # quote << AST::Text.new("quoted text")
18
+ class Quote < Element
19
+ # @return [String, nil] the author/username of the quote
20
+ attr_reader :author
21
+
22
+ # @return [String, nil] the post ID for Discourse quotes
23
+ attr_reader :post
24
+
25
+ # @return [String, nil] the topic ID for Discourse quotes
26
+ attr_reader :topic
27
+
28
+ # @return [String, nil] the username for Discourse quotes
29
+ attr_reader :username
30
+
31
+ # Create a new Quote element.
32
+ #
33
+ # @param author [String, nil] the author attribution
34
+ # @param post [String, nil] the post ID (Discourse-specific)
35
+ # @param topic [String, nil] the topic ID (Discourse-specific)
36
+ # @param username [String, nil] the username (Discourse-specific)
37
+ def initialize(author: nil, post: nil, topic: nil, username: nil)
38
+ super()
39
+ @author = author
40
+ @post = post
41
+ @topic = topic
42
+ @username = username
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module AST
5
+ # Represents sized text.
6
+ # Note: Discourse doesn't support inline size changes by default,
7
+ # but this preserves size information for migration/custom rendering.
8
+ #
9
+ # @example Sized text
10
+ # size = AST::Size.new(size: "20")
11
+ # size << AST::Text.new("Big text")
12
+ class Size < Element
13
+ # @return [String, nil] the font size value
14
+ attr_reader :size
15
+
16
+ # Create a new Size element.
17
+ #
18
+ # @param size [String, nil] font size value
19
+ def initialize(size: nil)
20
+ super()
21
+ @size = size
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module AST
5
+ # Represents a spoiler element (hidden content).
6
+ #
7
+ # @example Basic spoiler
8
+ # spoiler = AST::Spoiler.new
9
+ # spoiler << AST::Text.new("Hidden content")
10
+ #
11
+ # @example Spoiler with title
12
+ # spoiler = AST::Spoiler.new(title: "Click to reveal")
13
+ # spoiler << AST::Text.new("Hidden content")
14
+ class Spoiler < Element
15
+ # @return [String, nil] the spoiler title/label
16
+ attr_reader :title
17
+
18
+ # Create a new Spoiler element.
19
+ #
20
+ # @param title [String, nil] optional title for the spoiler
21
+ def initialize(title: nil)
22
+ super()
23
+ @title = title
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module AST
5
+ # Represents strikethrough/deleted text formatting.
6
+ #
7
+ # @example
8
+ # strikethrough = AST::Strikethrough.new
9
+ # strikethrough << AST::Text.new("deleted text")
10
+ class Strikethrough < Element
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module AST
5
+ # Represents subscript text (e.g., for chemical formulas).
6
+ #
7
+ # @example Subscript text
8
+ # sub = AST::Subscript.new
9
+ # sub << AST::Text.new("2") # For H2O
10
+ class Subscript < Element
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module AST
5
+ # Represents superscript text (e.g., for exponents).
6
+ #
7
+ # @example Superscript text
8
+ # sup = AST::Superscript.new
9
+ # sup << AST::Text.new("2") # For x^2
10
+ class Superscript < Element
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module AST
5
+ # Represents a text node (leaf node) in the AST.
6
+ # Text nodes contain the actual text content and cannot have children.
7
+ #
8
+ # @example Creating a text node
9
+ # text = AST::Text.new("Hello, world!")
10
+ #
11
+ # @example Merging text nodes
12
+ # text1 = AST::Text.new("Hello")
13
+ # text2 = AST::Text.new(" world")
14
+ # text1.merge(text2)
15
+ # text1.text # => "Hello world"
16
+ class Text < Node
17
+ # @return [String] the text content of this node
18
+ attr_reader :text
19
+
20
+ # Create a new text node with the given content.
21
+ #
22
+ # @param text [String] the text content
23
+ def initialize(text)
24
+ @text = +text
25
+ end
26
+
27
+ # Merge another text node's content into this one.
28
+ # This mutates the current text node by appending the other's text.
29
+ #
30
+ # @param other [Text] the text node to merge from
31
+ # @return [Text] self for method chaining
32
+ def merge(other)
33
+ @text << other.text
34
+ self
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module AST
5
+ # Represents underlined text formatting.
6
+ #
7
+ # @example
8
+ # underline = AST::Underline.new
9
+ # underline << AST::Text.new("underlined text")
10
+ class Underline < Element
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module AST
5
+ # Represents a Discourse upload reference (image or file attachment).
6
+ # Uses the upload:// URL scheme.
7
+ #
8
+ # @example Image upload
9
+ # upload = AST::Upload.new(
10
+ # sha1: "RBhXLF6381Te3mneJQNnnyNNt5",
11
+ # filename: "image.png",
12
+ # type: :image,
13
+ # alt: "My image",
14
+ # dimensions: "64x64"
15
+ # )
16
+ #
17
+ # @example File attachment
18
+ # upload = AST::Upload.new(
19
+ # sha1: "ppJp89TTiLOo6Vl4mAmo21MV28w",
20
+ # filename: "document.pdf",
21
+ # type: :attachment,
22
+ # size: "502.1 KB"
23
+ # )
24
+ class Upload < Node
25
+ # @return [String] the base62 SHA1 identifier from upload:// URL
26
+ attr_reader :sha1
27
+
28
+ # @return [String, nil] original filename
29
+ attr_reader :filename
30
+
31
+ # @return [Symbol] type of upload (:image or :attachment)
32
+ attr_reader :type
33
+
34
+ # @return [String, nil] alt text (for images)
35
+ attr_reader :alt
36
+
37
+ # @return [String, nil] dimensions string like "64x64" (for images)
38
+ attr_reader :dimensions
39
+
40
+ # @return [String, nil] file size string like "502.1 KB" (for attachments)
41
+ attr_reader :size
42
+
43
+ # @return [String, nil] the original raw Markdown
44
+ attr_reader :raw
45
+
46
+ # Create a new Upload node.
47
+ #
48
+ # @param sha1 [String] the base62 SHA1 identifier
49
+ # @param filename [String, nil] original filename
50
+ # @param type [Symbol] type of upload (:image or :attachment)
51
+ # @param alt [String, nil] alt text
52
+ # @param dimensions [String, nil] dimensions string
53
+ # @param size [String, nil] file size string
54
+ # @param raw [String, nil] the original raw Markdown
55
+ def initialize(
56
+ sha1:,
57
+ filename: nil,
58
+ type: :image,
59
+ alt: nil,
60
+ dimensions: nil,
61
+ size: nil,
62
+ raw: nil
63
+ )
64
+ @sha1 = sha1
65
+ @filename = filename
66
+ @type = type
67
+ @alt = alt
68
+ @dimensions = dimensions
69
+ @size = size
70
+ @raw = raw
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module AST
5
+ # Represents a hyperlink/URL element.
6
+ #
7
+ # @example URL with explicit href
8
+ # url = AST::Url.new(href: "https://example.com")
9
+ # url << AST::Text.new("Click here")
10
+ #
11
+ # @example URL with text as href
12
+ # url = AST::Url.new(href: "https://example.com")
13
+ # url << AST::Text.new("https://example.com")
14
+ class Url < Element
15
+ # @return [String, nil] the URL/href for this link
16
+ attr_reader :href
17
+
18
+ # Create a new URL element.
19
+ #
20
+ # @param href [String, nil] the URL/href for this link
21
+ def initialize(href: nil)
22
+ super()
23
+ @href = href
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # base / dependency order first
4
+ require_relative "ast/node"
5
+ require_relative "ast/element"
6
+ require_relative "ast/document"
7
+
8
+ require_relative "ast/align"
9
+ require_relative "ast/attachment"
10
+ require_relative "ast/bold"
11
+ require_relative "ast/code"
12
+ require_relative "ast/color"
13
+ require_relative "ast/email"
14
+ require_relative "ast/heading"
15
+ require_relative "ast/horizontal_rule"
16
+ require_relative "ast/image"
17
+ require_relative "ast/italic"
18
+ require_relative "ast/line_break"
19
+ require_relative "ast/list"
20
+ require_relative "ast/list_item"
21
+ require_relative "ast/paragraph"
22
+ require_relative "ast/quote"
23
+ require_relative "ast/size"
24
+ require_relative "ast/spoiler"
25
+ require_relative "ast/strikethrough"
26
+ require_relative "ast/subscript"
27
+ require_relative "ast/superscript"
28
+ require_relative "ast/text"
29
+ require_relative "ast/markdown_text"
30
+ require_relative "ast/underline"
31
+ require_relative "ast/url"
32
+
33
+ # Discourse-specific nodes
34
+ require_relative "ast/event"
35
+ require_relative "ast/mention"
36
+ require_relative "ast/poll"
37
+ require_relative "ast/upload"
38
+
39
+ module Markbridge
40
+ module AST
41
+ end
42
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ class Configuration
5
+ attr_accessor :escape_hard_line_breaks
6
+
7
+ def initialize
8
+ @escape_hard_line_breaks = false
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module GemLoader
5
+ class << self
6
+ def require_gem(gem, feature:)
7
+ require gem.to_s
8
+ rescue LoadError
9
+ raise LoadError, missing_message(gem, feature)
10
+ end
11
+
12
+ private
13
+
14
+ def missing_message(gem, feature)
15
+ gem_name = gem.to_s
16
+ [
17
+ "#{gem_name.capitalize} is required for #{feature}.",
18
+ "Add 'gem \"#{gem_name}\"' to your Gemfile or install it with 'gem install #{gem_name}'.",
19
+ ].join(" ")
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+ module Markbridge
3
+ module Parsers
4
+ module BBCode
5
+ module ClosingStrategies
6
+ class Base
7
+ def initialize(reconciler)
8
+ @reconciler = reconciler
9
+ end
10
+
11
+ def handle_close(token:, context:, registry:, tokens: nil)
12
+ return unless context.current.is_a?(AST::Element)
13
+
14
+ current_handler = registry.handler_for_element(context.current)
15
+ closing_handler = registry[token.tag]
16
+
17
+ if current_handler == closing_handler
18
+ context.pop
19
+ elsif try_reorder(context:, tokens:, closing_handler:)
20
+ # Reordering handled
21
+ elsif @reconciler.try_auto_close(handler: closing_handler, context:)
22
+ # Auto-close succeeded
23
+ else
24
+ context.add_child(AST::Text.new(token.source))
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def try_reorder(context:, tokens:, closing_handler:)
31
+ false
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+ module Markbridge
3
+ module Parsers
4
+ module BBCode
5
+ module ClosingStrategies
6
+ class Reordering < Base
7
+ private
8
+
9
+ def try_reorder(context:, tokens:, closing_handler:)
10
+ return false unless tokens
11
+ @reconciler.try_reorder(handler: closing_handler, tokens:, context:)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+ module Markbridge
3
+ module Parsers
4
+ module BBCode
5
+ module ClosingStrategies
6
+ class Strict < Base
7
+ # Inherits Base#handle_close; no changes required for strict behavior
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Parsers
5
+ module BBCode
6
+ module ClosingStrategies
7
+ # Encapsulates logic for reconciling mismatched closing tags
8
+ class TagReconciler
9
+ MAX_AUTO_CLOSE_DEPTH = 5
10
+
11
+ def initialize(registry:)
12
+ @registry = registry
13
+ end
14
+
15
+ # Attempt to auto-close tags to match a closing tag
16
+ #
17
+ # @param handler [BaseHandler] the handler for the closing tag
18
+ # @param context [ParserState]
19
+ # @return [Boolean] true if successful, false if auto-close not possible
20
+ def try_auto_close(handler:, context:)
21
+ match_depth = find_matching_handler_depth(handler, context)
22
+
23
+ return false if match_depth.nil? || match_depth >= MAX_AUTO_CLOSE_DEPTH
24
+ return false unless all_auto_closeable?(context, match_depth)
25
+
26
+ count = match_depth + 1
27
+ count.times { context.pop }
28
+ context.auto_close!(count)
29
+
30
+ true
31
+ end
32
+
33
+ # Attempt to reorder closing tags
34
+ #
35
+ # @param handler [BaseHandler] the handler for the closing tag
36
+ # @param tokens [Object] the token stream
37
+ # @param context [ParserState]
38
+ # @return [Boolean] true if successful, false otherwise
39
+ def try_reorder(handler:, tokens:, context:)
40
+ match_depth = find_matching_handler_depth(handler, context)
41
+ return false if match_depth.nil? || match_depth >= MAX_AUTO_CLOSE_DEPTH
42
+
43
+ opening_handlers = collect_auto_closeable_handlers(context, match_depth)
44
+ return false if opening_handlers.empty?
45
+
46
+ closing_handlers = [handler]
47
+ closing_handlers.concat(peek_closing_handlers(tokens, opening_handlers.size - 1))
48
+ return false if closing_handlers.size != opening_handlers.size
49
+ unless opening_handlers.sort_by(&:object_id) == closing_handlers.sort_by(&:object_id)
50
+ return false
51
+ end
52
+
53
+ # Consume the extra closing tags
54
+ (opening_handlers.size - 1).times do
55
+ peeked = tokens.peek
56
+ break unless peeked.is_a?(TagEndToken)
57
+ tokens.next
58
+ end
59
+
60
+ opening_handlers.each { context.pop }
61
+ context.auto_close!(opening_handlers.size)
62
+
63
+ true
64
+ end
65
+
66
+ private
67
+
68
+ def find_matching_handler_depth(handler, context)
69
+ elements = context.elements_from_current(MAX_AUTO_CLOSE_DEPTH)
70
+
71
+ elements.each_with_index do |element, depth|
72
+ next unless element.is_a?(AST::Element)
73
+
74
+ element_handler = @registry.handler_for_element(element)
75
+ return depth if element_handler == handler
76
+ end
77
+
78
+ nil
79
+ end
80
+
81
+ def all_auto_closeable?(context, target_depth)
82
+ context
83
+ .elements_from_current(target_depth)
84
+ .all? { |element| @registry.auto_closeable?(element.class) }
85
+ end
86
+
87
+ def collect_auto_closeable_handlers(context, target_depth)
88
+ handlers = []
89
+
90
+ context
91
+ .elements_from_current(target_depth)
92
+ .each do |element|
93
+ return [] unless @registry.auto_closeable?(element.class)
94
+
95
+ handler = @registry.handler_for_element(element)
96
+ handlers << handler if handler
97
+ end
98
+
99
+ handlers
100
+ end
101
+
102
+ def peek_closing_handlers(tokens, max_count)
103
+ handlers = []
104
+ peeked_tokens = tokens.peek_ahead(max_count)
105
+
106
+ peeked_tokens.each do |token|
107
+ break unless token.is_a?(TagEndToken)
108
+
109
+ handler = @registry[token.tag]
110
+ break unless handler
111
+
112
+ handlers << handler
113
+ end
114
+
115
+ handlers
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markbridge
4
+ module Parsers
5
+ module BBCode
6
+ class MaxDepthExceededError < StandardError
7
+ def initialize(max)
8
+ super("maximum parsing depth (#{max}) exceeded")
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end