mdom 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 46b3e2018949cf138e98983729796223a7388a612d3b8a7257a55abd9f1bcb53
4
+ data.tar.gz: df13bcf4a9d0aac9a9e7150a80a9cdf82ba6081a02cfeb1d1d43b63bb467cbbb
5
+ SHA512:
6
+ metadata.gz: 8662a2697905070ec926e79d9b47dfc9753747dc43437a9498304db9f353d848f13b4cd73113663826c4e9bffb887204fb274ca39c6a0c984f368f95696c6a3f
7
+ data.tar.gz: 0e4e44ba65089ab2562715fba447ecc5cc0ae1211e700424692c13a8ed98fc5e94bc8145f46a7c9566b26e1d8d4ca594a1c77862c54a7937677c6a09a8373a74
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MDOM
4
+ # Tokenizes a run of inline text into inline nodes: Text, Emphasis, Strong,
5
+ # Code, Link, Image, Softbreak, Hardbreak and escaped characters.
6
+ #
7
+ # This is a pragmatic subset of CommonMark inline syntax. Code spans and
8
+ # links/images are matched textually; emphasis/strong is resolved by finding
9
+ # the next matching delimiter and recursing into the enclosed text. Delimiter
10
+ # runs and exact flanking rules are intentionally simplified.
11
+ module InlineParser
12
+ ESCAPABLE = /[\x00`*_{}\[\]()#+\-.!>]/.freeze
13
+ SPECIAL = /[\\`!*_\[\n]/.freeze
14
+
15
+ module_function
16
+
17
+ def parse(source)
18
+ Scanner.new(source.to_s).read_nodes
19
+ end
20
+
21
+ class Scanner
22
+ def initialize(text)
23
+ @text = text
24
+ @len = text.length
25
+ @pos = 0
26
+ end
27
+
28
+ def read_nodes
29
+ nodes = []
30
+ while @pos < @len
31
+ node = read_node
32
+ nodes << node if node
33
+ end
34
+ nodes
35
+ end
36
+
37
+ private
38
+
39
+ def read_node
40
+ case @text[@pos]
41
+ when "\\" then read_escape
42
+ when "`" then read_code_span
43
+ when "!" then read_link_or_image
44
+ when "[" then read_link_or_image
45
+ when "\n" then read_break
46
+ when "*" then read_emphasis
47
+ when "_" then read_emphasis
48
+ else read_plain_run
49
+ end
50
+ end
51
+
52
+ def read_escape
53
+ nxt = @text[@pos + 1]
54
+ if nxt && ESCAPABLE.match?(nxt)
55
+ @pos += 2
56
+ Text.new(nxt)
57
+ else
58
+ @pos += 1
59
+ Text.new("\\")
60
+ end
61
+ end
62
+
63
+ def read_code_span
64
+ count = 1
65
+ count += 1 while @text[@pos + count] == "`"
66
+ closer = "`" * count
67
+ @pos += count
68
+ closing = @text.index(closer, @pos)
69
+ if closing.nil?
70
+ @pos -= count
71
+ read_plain_run
72
+ else
73
+ content = @text[@pos...closing].gsub(/\A +| +\z/, "").gsub(/\n/, " ")
74
+ @pos = closing + count
75
+ Code.new(content)
76
+ end
77
+ end
78
+
79
+ def read_link_or_image
80
+ image = @text[@pos] == "!"
81
+ @pos += 1 if image
82
+ return read_plain_run unless @text[@pos] == "["
83
+
84
+ label_start = @pos + 1
85
+ closing = @text.index("]", label_start)
86
+ return read_plain_run unless closing
87
+
88
+ label = @text[label_start...closing]
89
+ return read_plain_run unless @text[closing + 1] == "("
90
+
91
+ dest_end = @text.index(")", closing + 1)
92
+ return read_plain_run unless dest_end
93
+
94
+ dest, title = split_destination(@text[(closing + 2)...dest_end])
95
+ @pos = dest_end + 1
96
+ if image
97
+ Image.new(dest, alt: label, title: title)
98
+ else
99
+ Link.new(dest, title: title, children: InlineParser.parse(label))
100
+ end
101
+ end
102
+
103
+ def split_destination(raw)
104
+ raw = raw.strip
105
+ raw = raw[1..-2] if raw.start_with?("<") && raw.end_with?(">")
106
+ body = raw
107
+ title = nil
108
+ if (m = /\A(.+?)\s+(?:"([^"]*)"|'([^']*)'|\(([^)]*)\))\z/.match(raw))
109
+ body = m[1]
110
+ title = m[2] || m[3] || m[4]
111
+ end
112
+ [body, title]
113
+ end
114
+
115
+ # A newline preceded by two spaces is a hard break; otherwise soft break.
116
+ def read_break
117
+ # Two spaces immediately before the newline => hard break.
118
+ before = @text[0...@pos]
119
+ hard = before.end_with?(" ")
120
+ @pos += 1
121
+ hard ? Hardbreak.new : Softbreak.new
122
+ end
123
+
124
+ def read_emphasis
125
+ # Double marker -> strong, single -> emphasis.
126
+ if @text[@pos, 2] == "**" || @text[@pos, 2] == "__"
127
+ open = @text[@pos, 2]
128
+ idx = @pos + 2
129
+ closer = find_closer(idx, open[0], open.length)
130
+ if closer
131
+ inner = @text[idx...closer]
132
+ @pos = closer + open.length
133
+ Strong.new(children: InlineParser.parse(inner))
134
+ else
135
+ read_plain_run
136
+ end
137
+ else
138
+ open_ch = @text[@pos]
139
+ idx = @pos + 1
140
+ # A single marker must close on a lone delimiter, not part of "**".
141
+ closer = find_closer(idx, open_ch, 1, skip_double: true)
142
+ if closer
143
+ inner = @text[idx...closer]
144
+ @pos = closer + 1
145
+ Emphasis.new(children: InlineParser.parse(inner))
146
+ else
147
+ read_plain_run
148
+ end
149
+ end
150
+ end
151
+
152
+ # Find the index of a closing delimiter, scanning from +from+. With
153
+ # +skip_double+, a doubled delimiter is skipped (so "*a **b** c*" resolves
154
+ # the outer emphasis to the final lone '*', recursing into the strong).
155
+ def find_closer(from, ch, width, skip_double: false)
156
+ i = from
157
+ while i < @len
158
+ if @text[i] == ch
159
+ if width > 1
160
+ return i if @text[i, width] == ch * width
161
+ elsif @text[i, 2] == ch * 2
162
+ i += 2 # skip doubled marker entirely
163
+ next
164
+ elsif @text[i, width] == ch * width
165
+ return i
166
+ end
167
+ end
168
+ i += 1
169
+ end
170
+ nil
171
+ end
172
+
173
+ def read_plain_run
174
+ start = @pos
175
+ while @pos < @len && !SPECIAL.match?(@text[@pos])
176
+ @pos += 1
177
+ end
178
+ # Guarantee forward progress so a special char that couldn't be parsed
179
+ # as its own construct is treated as literal text.
180
+ @pos += 1 if @pos == start
181
+ Text.new(@text[start...@pos])
182
+ end
183
+ end
184
+ end
185
+ end
data/lib/mdom/node.rb ADDED
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MDOM
4
+ # Base class for every node in the Markdown tree.
5
+ #
6
+ # A node carries a +type+ (a Symbol), an ordered list of +children+, and
7
+ # optional +attributes+ (a Hash). The +parent+ of a node is maintained
8
+ # automatically whenever a child is appended or removed.
9
+ class Node
10
+ attr_reader :type, :children, :attributes
11
+ attr_accessor :parent
12
+
13
+ # -- construction -------------------------------------------------------
14
+
15
+ def initialize(type, attributes: {}, children: [])
16
+ @type = type
17
+ @attributes = attributes
18
+ @children = []
19
+ @parent = nil
20
+ children.each { |child| append(child) }
21
+ end
22
+
23
+ # -- children -----------------------------------------------------------
24
+
25
+ # Append +child+ to the end of this node's children. Reparents +child+
26
+ # away from any previous parent. Returns +self+ so it can be chained.
27
+ def append(child)
28
+ return self if child.nil?
29
+
30
+ child.remove if child.parent
31
+ child.parent = self
32
+ @children << child
33
+ self
34
+ end
35
+ alias << append
36
+
37
+ # Yield each child in order. Returns an Enumerator when no block is given.
38
+ def each_child(&block)
39
+ return enum_for(:each_child) unless block
40
+
41
+ @children.each(&block)
42
+ self
43
+ end
44
+ alias each each_child
45
+
46
+ def children?
47
+ !@children.empty?
48
+ end
49
+
50
+ # Remove this node from its current parent, if any. Returns +self+.
51
+ def remove
52
+ @parent&.children&.delete(self)
53
+ @parent = nil
54
+ self
55
+ end
56
+
57
+ # -- tree navigation ----------------------------------------------------
58
+
59
+ # The topmost ancestor (a node whose parent is nil).
60
+ def root
61
+ node = self
62
+ node = node.parent while node.parent
63
+ node
64
+ end
65
+
66
+ # Depth in the tree: 0 at the root, 1 for its direct children, etc.
67
+ def depth
68
+ return 0 if @parent.nil?
69
+
70
+ @parent.depth + 1
71
+ end
72
+
73
+ # The chain of ancestors, closest first. Returns [] for a root node.
74
+ def ancestors
75
+ list = []
76
+ node = @parent
77
+ while node
78
+ list << node
79
+ node = node.parent
80
+ end
81
+ list
82
+ end
83
+
84
+ # -- traversal and query -------------------------------------------------
85
+
86
+ # Depth-first (preorder) traversal that yields this node then its
87
+ # descendants, depth-first. Returns an Enumerator when no block is given.
88
+ def walk(&block)
89
+ return enum_for(:walk) unless block
90
+
91
+ block.call(self)
92
+ @children.each { |child| child.walk(&block) }
93
+ self
94
+ end
95
+
96
+ # Yield each descendant (including self) whose type matches +type+, or all
97
+ # nodes when no type is given. With a block given, also yields when the
98
+ # block returns truthy.
99
+ def find_all(type = nil)
100
+ results = []
101
+ walk do |node|
102
+ next unless type.nil? || node.type == type
103
+ next if block_given? && !yield(node)
104
+
105
+ results << node
106
+ end
107
+ results
108
+ end
109
+
110
+ # The first matching descendant (or self); +type+ may be a Symbol or a
111
+ # callable, or a block may be supplied.
112
+ def find(type = nil, &block)
113
+ predicate =
114
+ if block
115
+ block
116
+ elsif type.is_a?(Symbol)
117
+ ->(n) { n.type == type }
118
+ elsif type.respond_to?(:call)
119
+ type
120
+ else
121
+ nil
122
+ end
123
+ walk { |node| return node if predicate && predicate.call(node) }
124
+ nil
125
+ end
126
+
127
+ # Query by one or more types (Symbol or array of Symbols). Returns an array
128
+ # of matching nodes (including self if it matches) in document order.
129
+ def [](*types)
130
+ types = types.flatten
131
+ find_all.select { |n| types.include?(n.type) }
132
+ end
133
+
134
+ # -- mutation -----------------------------------------------------------
135
+
136
+ # Insert +node+ as the sibling immediately before this node.
137
+ def insert_before(node)
138
+ parent = @parent
139
+ raise "cannot insert before a root node" if parent.nil?
140
+
141
+ node.remove if node.parent
142
+ idx = parent.children.index(self)
143
+ parent.children.insert(idx, node)
144
+ node.parent = parent
145
+ self
146
+ end
147
+
148
+ # Insert +node+ as the sibling immediately after this node.
149
+ def insert_after(node)
150
+ parent = @parent
151
+ raise "cannot insert after a root node" if parent.nil?
152
+
153
+ node.remove if node.parent
154
+ idx = parent.children.index(self)
155
+ parent.children.insert(idx + 1, node)
156
+ node.parent = parent
157
+ self
158
+ end
159
+
160
+ # Replace this node in its parent with +node+. Returns +node+.
161
+ def replace(node)
162
+ parent = @parent
163
+ raise "cannot replace a root node" if parent.nil?
164
+
165
+ node.remove if node.parent
166
+ idx = parent.children.index(self)
167
+ parent.children[idx] = node
168
+ node.parent = parent
169
+ @parent = nil
170
+ node
171
+ end
172
+
173
+ # -- misc ---------------------------------------------------------------
174
+
175
+ def text?
176
+ @type == :text
177
+ end
178
+
179
+ # Concatenate the literal string content of this subtree. Leaves that carry
180
+ # text expose it via +to_s+ (Text, Code); container nodes recurse.
181
+ def plain_text
182
+ return "\n" if @type == :softbreak || @type == :hardbreak
183
+ return to_s.to_s if @children.empty?
184
+
185
+ @children.map(&:plain_text).join
186
+ end
187
+
188
+ # Serialize this node (and its subtree) back to Markdown.
189
+ def to_markdown
190
+ MDOM::Serializer.serialize(self)
191
+ end
192
+ alias markdown to_markdown
193
+
194
+ def empty?
195
+ @children.empty?
196
+ end
197
+
198
+ # A compact, single-line summary of this node (its own type/attributes only,
199
+ # without recursing into children).
200
+ def to_s
201
+ "#<#{short_class}#{detail}>"
202
+ end
203
+
204
+ # A human-readable tree rendering of this node and its descendants.
205
+ def inspect
206
+ pretty_print
207
+ end
208
+
209
+ # Render this node plus its subtree as an indented, printable tree.
210
+ def pretty_print(level = 0)
211
+ indent = " " * level
212
+ children = @children.empty? ? "" : "\n" + @children.map { |c| c.pretty_print(level + 1) }.join("\n")
213
+ "#{indent}#<#{short_class}#{detail}>#{children}"
214
+ end
215
+
216
+ private
217
+
218
+ # The short class name (e.g. "MDOM::Paragraph" -> "Paragraph"), falling back
219
+ # to the type symbol for plain nodes.
220
+ def short_class
221
+ name = self.class.name
222
+ name ? name.split("::").last : type.inspect
223
+ end
224
+
225
+ # A short description of this node's own salient data, e.g. ' level=2' for a
226
+ # heading, ' "text"' for a text node.
227
+ def detail
228
+ case type
229
+ when Types::TEXT, Types::CODE
230
+ " #{value.inspect}"
231
+ when Types::CODE_BLOCK
232
+ lang = attributes[:lang] ? "lang=#{attributes[:lang].inspect} " : ""
233
+ " #{lang}literal=#{literal.inspect}"
234
+ when Types::HEADING
235
+ " level=#{level}"
236
+ when Types::LIST
237
+ ordered = attributes[:ordered] ? "ordered" : "unordered"
238
+ start = attributes[:start] ? ", start=#{attributes[:start]}" : ""
239
+ " #{ordered}#{start}"
240
+ when Types::LIST_ITEM
241
+ task = attributes[:task] ? ", task" : ""
242
+ checked = attributes[:checked] ? "=checked" : ""
243
+ task.empty? ? "" : " (#{task.sub(', ', '')}#{checked})"
244
+ when Types::LINK
245
+ title = attributes[:title] ? " title=#{attributes[:title].inspect}" : ""
246
+ " #{attributes[:destination].inspect}#{title}"
247
+ when Types::IMAGE
248
+ title = attributes[:title] ? " title=#{attributes[:title].inspect}" : ""
249
+ " #{attributes[:destination].inspect} alt=#{attributes[:alt].inspect}#{title}"
250
+ else
251
+ attributes.empty? ? "" : " #{attributes.inspect}"
252
+ end
253
+ end
254
+ end
255
+ end
data/lib/mdom/nodes.rb ADDED
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MDOM
4
+ # Node type constants, shared across the code base.
5
+ module Types
6
+ DOCUMENT = :document
7
+ HEADING = :heading
8
+ PARAGRAPH = :paragraph
9
+ LIST = :list
10
+ LIST_ITEM = :list_item
11
+ LIST_ITEM_LINE = :list_item_line
12
+ BLOCKQUOTE = :blockquote
13
+ CODE_BLOCK = :code_block
14
+ HRULE = :hrule
15
+
16
+ TEXT = :text
17
+ EMPHASIS = :emphasis
18
+ STRONG = :strong
19
+ CODE = :code
20
+ LINK = :link
21
+ IMAGE = :image
22
+ SOFTBREAK = :softbreak
23
+ HARDBREAK = :hardbreak
24
+ end
25
+
26
+ # -- Block nodes ----------------------------------------------------------
27
+
28
+ # Each concrete node class fixes its +type+ and declares which children it
29
+ # accepts. Subclassing Node keeps everything a Node so traversal is uniform.
30
+ class BlockNode < Node
31
+ end
32
+
33
+ class Document < BlockNode
34
+ def initialize(children: [])
35
+ super(Types::DOCUMENT, children: children)
36
+ end
37
+ end
38
+
39
+ class Heading < BlockNode
40
+ attr_reader :level
41
+
42
+ def initialize(level, children: [], attributes: {})
43
+ @level = level.to_i
44
+ super(Types::HEADING, attributes: attributes.merge(level: @level), children: children)
45
+ end
46
+ end
47
+
48
+ class Paragraph < BlockNode
49
+ def initialize(children: [])
50
+ super(Types::PARAGRAPH, children: children)
51
+ end
52
+ end
53
+
54
+ class List < BlockNode
55
+ # ordered: true/false, tight: true/false, start: integer for ordered lists
56
+ def initialize(ordered: false, tight: true, start: nil, children: [], attributes: {})
57
+ attrs = { ordered: ordered, tight: tight }.merge(attributes)
58
+ attrs[:start] = start unless start.nil?
59
+ super(Types::LIST, attributes: attrs, children: children)
60
+ end
61
+
62
+ def ordered?
63
+ attributes[:ordered]
64
+ end
65
+ end
66
+
67
+ class ListItem < BlockNode
68
+ def initialize(children: [])
69
+ super(Types::LIST_ITEM, children: children)
70
+ end
71
+ end
72
+
73
+ class Blockquote < BlockNode
74
+ def initialize(children: [])
75
+ super(Types::BLOCKQUOTE, children: children)
76
+ end
77
+ end
78
+
79
+ class CodeBlock < BlockNode
80
+ # lang: language string (may be nil), fenced: true/false, literal: source text
81
+ def initialize(literal, lang: nil, fenced: true, attributes: {})
82
+ attrs = { fenced: fenced }.merge(attributes)
83
+ attrs[:lang] = lang unless lang.nil?
84
+ super(Types::CODE_BLOCK, attributes: attrs)
85
+ # Code block text is kept as a raw string, not child nodes.
86
+ @literal = literal
87
+ end
88
+
89
+ attr_reader :literal
90
+
91
+ def lang
92
+ attributes[:lang]
93
+ end
94
+
95
+ def fenced?
96
+ attributes[:fenced]
97
+ end
98
+
99
+ def to_s
100
+ "#<MDOM::CodeBlock fenced=#{fenced?} lang=#{lang.inspect}>"
101
+ end
102
+ end
103
+
104
+ class Hrule < BlockNode
105
+ def initialize(attributes: {})
106
+ super(Types::HRULE, attributes: attributes)
107
+ end
108
+ end
109
+
110
+ def self.allocate_node(type, **attrs)
111
+ case type
112
+ when Types::HEADING then Heading.new(attrs.fetch(:level, 1), attributes: attrs[:attributes])
113
+ when Types::PARAGRAPH then Paragraph.new
114
+ when Types::LIST then List.new(**attrs)
115
+ when Types::LIST_ITEM then ListItem.new
116
+ when Types::BLOCKQUOTE then Blockquote.new
117
+ when Types::HRULE then Hrule.new
118
+ else
119
+ BlockNode.new(type, attributes: attrs)
120
+ end
121
+ end
122
+
123
+ # -- Inline nodes ---------------------------------------------------------
124
+
125
+ class InlineNode < Node
126
+ end
127
+
128
+ class Text < InlineNode
129
+ def initialize(value)
130
+ @value = value
131
+ super(Types::TEXT)
132
+ end
133
+
134
+ attr_reader :value
135
+
136
+ def to_s
137
+ @value
138
+ end
139
+ end
140
+
141
+ class Emphasis < InlineNode
142
+ def initialize(children: [])
143
+ super(Types::EMPHASIS, children: children)
144
+ end
145
+ end
146
+
147
+ class Strong < InlineNode
148
+ def initialize(children: [])
149
+ super(Types::STRONG, children: children)
150
+ end
151
+ end
152
+
153
+ class Code < InlineNode
154
+ def initialize(value)
155
+ @value = value
156
+ super(Types::CODE)
157
+ end
158
+
159
+ attr_reader :value
160
+
161
+ def to_s
162
+ @value
163
+ end
164
+ end
165
+
166
+ class Link < InlineNode
167
+ def initialize(dest, title: nil, children: [])
168
+ attrs = { destination: dest }
169
+ attrs[:title] = title unless title.nil?
170
+ super(Types::LINK, attributes: attrs, children: children)
171
+ end
172
+
173
+ def destination
174
+ attributes[:destination]
175
+ end
176
+
177
+ def title
178
+ attributes[:title]
179
+ end
180
+ end
181
+
182
+ class Image < InlineNode
183
+ def initialize(dest, alt: nil, title: nil)
184
+ attrs = { destination: dest }
185
+ attrs[:alt] = alt unless alt.nil?
186
+ attrs[:title] = title unless title.nil?
187
+ super(Types::IMAGE, attributes: attrs)
188
+ end
189
+
190
+ def destination
191
+ attributes[:destination]
192
+ end
193
+
194
+ def alt
195
+ attributes[:alt]
196
+ end
197
+
198
+ def title
199
+ attributes[:title]
200
+ end
201
+ end
202
+
203
+ class Softbreak < InlineNode
204
+ def initialize
205
+ super(Types::SOFTBREAK)
206
+ end
207
+ end
208
+
209
+ class Hardbreak < InlineNode
210
+ def initialize
211
+ super(Types::HARDBREAK)
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MDOM
4
+ module Parser
5
+ extend self
6
+
7
+ HEADING_RE = /\A(\#{1,6})[ \t]+(.*?)[ \t]*\z/.freeze
8
+ SETEXT_RE = /\A\s*([=-]+)\s*\z/.freeze
9
+ FENCE_RE = /\A([ ]{0,3})(`{3,}|~{3,})(.*)\z/.freeze
10
+ HRULE_RE = /\A\s*((\*\s*){3,}|(-\s*){3,}|(_\s*){3,})\z/.freeze
11
+ QUOTE_RE = /\A[ ]{0,3}>[ \t]?(.*)\z/.freeze
12
+ LIST_RE = /\A([ ]*)([-*+])([ \t]+)(.*)\z/.freeze
13
+ ORDERED_RE = /\A([ ]*)(\d{1,9})([.)])([ \t]+)(.*)\z/.freeze
14
+ TASK_RE = /\A\[([ xX])\][ \t]+(.*)\z/.freeze
15
+
16
+ def read(source)
17
+ Reader.new(normalize(source)).parse
18
+ end
19
+
20
+ def normalize(source)
21
+ s = source.to_s
22
+ s = s.gsub(/\r\n?/, "\n").sub(/\A\n+/, "").chomp
23
+ return [] if s.empty?
24
+
25
+ s.split("\n", -1).map { |line| expand_leading_tabs(line) }
26
+ end
27
+
28
+ # Expand leading tab characters in +line+ into spaces at 4-column tab
29
+ # stops. This normalizes list/quote indentation so the block parser can
30
+ # reason about column widths, while leaving the non-indentation content
31
+ # (including tabs inside code or text) untouched. A tab stop positions the
32
+ # next character at the next multiple of 4.
33
+ def expand_leading_tabs(line)
34
+ i = 0
35
+ col = 0
36
+ out = +""
37
+ while i < line.length
38
+ ch = line[i]
39
+ case ch
40
+ when " "
41
+ out << " "
42
+ col += 1
43
+ i += 1
44
+ when "\t"
45
+ target = (col / 4 + 1) * 4
46
+ out << " " * (target - col)
47
+ col = target
48
+ i += 1
49
+ else
50
+ out << line[i..]
51
+ break
52
+ end
53
+ end
54
+ out
55
+ end
56
+
57
+ # Line-oriented block scanner. parse_blocks returns a flat array of sibmling
58
+ # block nodes (and nothing else), so callers don't juggle an index.
59
+ class Reader
60
+ def initialize(lines)
61
+ @lines = lines
62
+ @pos = 0
63
+ end
64
+
65
+ def parse
66
+ Document.new(children: parse_blocks)
67
+ end
68
+
69
+ def self.parse_lines(lines)
70
+ new(lines).send(:parse_blocks)
71
+ end
72
+
73
+ private
74
+
75
+ def eof?
76
+ @pos >= @lines.length
77
+ end
78
+
79
+ def current
80
+ @lines[@pos]
81
+ end
82
+
83
+ def peek(offset = 1)
84
+ @lines[@pos + offset]
85
+ end
86
+
87
+ def blank?(line)
88
+ line.nil? || line.strip.empty?
89
+ end
90
+
91
+ def parse_blocks
92
+ blocks = []
93
+ while !eof?
94
+ if blank?(current)
95
+ @pos += 1
96
+ next
97
+ end
98
+
99
+ block = parse_block
100
+ blocks << block if block
101
+ end
102
+ blocks
103
+ end
104
+
105
+ def parse_block
106
+ line = current
107
+
108
+ if (m = FENCE_RE.match(line))
109
+ return parse_fenced_code(m) if fence_safe?(m)
110
+ end
111
+
112
+ if (om = ORDERED_RE.match(line))
113
+ return parse_list(om, ordered: true)
114
+ end
115
+ if (um = LIST_RE.match(line))
116
+ return parse_list(um, ordered: false)
117
+ end
118
+
119
+ if HRULE_RE.match(line)
120
+ @pos += 1
121
+ return Hrule.new
122
+ end
123
+
124
+ if (q = QUOTE_RE.match(line))
125
+ return parse_blockquote(q)
126
+ end
127
+
128
+ if (h = HEADING_RE.match(line))
129
+ text = h[2].sub(/[ \t]+#+[ \t]*\z/, "").strip
130
+ @pos += 1
131
+ return Heading.new(h[1].length, children: inline_children(text))
132
+ end
133
+
134
+ if setext_heading?
135
+ return parse_setext_heading
136
+ end
137
+
138
+ parse_paragraph
139
+ end
140
+
141
+ def fence_safe?(m)
142
+ return true unless m[2].start_with?("`")
143
+
144
+ !m[3].include?("`")
145
+ end
146
+
147
+ def parse_fenced_code(m)
148
+ indent = m[1].length
149
+ marker = m[2][0]
150
+ fence_len = m[2].length
151
+ info = m[3].strip
152
+ lang = info.empty? ? nil : info.split(/\s+/).first
153
+
154
+ @pos += 1
155
+ content = []
156
+ while !eof?
157
+ line = current
158
+ if (cm = FENCE_RE.match(line)) && cm[2][0] == marker && cm[2].length >= fence_len && cm[1].length <= 3
159
+ @pos += 1
160
+ break
161
+ end
162
+ content << line[indent..].to_s
163
+ @pos += 1
164
+ end
165
+ CodeBlock.new(content.join("\n"), lang: lang, fenced: true)
166
+ end
167
+
168
+ def setext_heading?
169
+ !blank?(current) && !HRULE_RE.match(current) && SETEXT_RE.match(peek(1).to_s)
170
+ end
171
+
172
+ def parse_setext_heading
173
+ text = current.strip
174
+ level = SETEXT_RE.match(peek(1))[1].start_with?("=") ? 1 : 2
175
+ @pos += 2
176
+ Heading.new(level, children: inline_children(text))
177
+ end
178
+
179
+ def parse_blockquote(quote)
180
+ quote_lines = []
181
+ while !eof? && (m = QUOTE_RE.match(current))
182
+ quote_lines << m[1]
183
+ @pos += 1
184
+ end
185
+ Blockquote.new(children: Reader.parse_lines(quote_lines))
186
+ end
187
+
188
+ def parse_list(marker, ordered:)
189
+ start = ordered ? marker[2].to_i : nil
190
+ items = []
191
+ while !eof?
192
+ line = current
193
+ lm = ORDERED_RE.match(line) || LIST_RE.match(line)
194
+ break if lm.nil?
195
+ break if (ORDERED_RE.match(line).nil?) == ordered
196
+
197
+ marker_len = marker_width(lm)
198
+ content = lm[5] || lm[4]
199
+ task = false
200
+ checked = false
201
+ if (tm = TASK_RE.match(content))
202
+ task = true
203
+ checked = tm[1].downcase == "x"
204
+ content = tm[2]
205
+ end
206
+ @pos += 1
207
+
208
+ item_lines = [content]
209
+ while !eof? && !blank?(current)
210
+ l = current
211
+ child = ORDERED_RE.match(l) || LIST_RE.match(l)
212
+ break if child && marker_width(child) <= marker_len
213
+
214
+ item_lines << l
215
+ @pos += 1
216
+ end
217
+
218
+ li = ListItem.new
219
+ Reader.parse_lines(item_lines).each { |n| li.append(n) }
220
+ li.attributes[:task] = true if task
221
+ li.attributes[:checked] = checked if task
222
+ items << li
223
+ end
224
+
225
+ list = List.new(ordered: ordered, tight: true, start: start)
226
+ items.each { |li| list.append(li) }
227
+ list
228
+ end
229
+
230
+ # Width in columns of a list marker prefix (indent + bullet/number + space).
231
+ def marker_width(m)
232
+ content = m[5] || m[4]
233
+ m[0].length - content.length
234
+ end
235
+
236
+ def parse_paragraph
237
+ collected = []
238
+ while !eof? && !blank?(current) && !block_start?(current)
239
+ collected << current.strip
240
+ @pos += 1
241
+ end
242
+ Paragraph.new(children: inline_children(collected.join("\n")))
243
+ end
244
+
245
+ def block_start?(line)
246
+ return true if FENCE_RE.match(line)
247
+ return true if LIST_RE.match(line) || ORDERED_RE.match(line)
248
+ return true if HRULE_RE.match(line)
249
+ return true if QUOTE_RE.match(line)
250
+ return true if HEADING_RE.match(line)
251
+
252
+ false
253
+ end
254
+
255
+ # Parse a run of inline text into inline nodes.
256
+ def inline_children(text)
257
+ return [] if text.to_s.empty?
258
+
259
+ nodes = InlineParser.parse(text)
260
+ trim_hardbreak_text(nodes)
261
+ end
262
+
263
+ # A hard break is represented by two trailing spaces before the newline.
264
+ # The text node emitted before it still carries those spaces; trim them so
265
+ # the model has the break (not literal spaces) as its semantic marker.
266
+ def trim_hardbreak_text(nodes)
267
+ out = []
268
+ nodes.each_with_index do |node, i|
269
+ if node.text? && nodes[i + 1]&.type == :hardbreak
270
+ value = node.value.sub(/ {2}\z/, "")
271
+ out << (value.empty? ? nil : Text.new(value))
272
+ else
273
+ out << node
274
+ end
275
+ end
276
+ out.compact
277
+ end
278
+ end
279
+ end
280
+
281
+ # Public entry point: parse +source+ into an MDOM::Document tree.
282
+ def self.parse(source)
283
+ Parser.read(source)
284
+ end
285
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MDOM
4
+ # Serializes an MDOM tree back to a Markdown string. Traversal is driven by
5
+ # node type; the context (indentation level, in-blockquote flag, list marker)
6
+ # flows downward so nested lists and blockquotes render correctly.
7
+ class Serializer
8
+ # Serialize +node+ to Markdown. Returns a String.
9
+ def self.serialize(node, indent: 0, blockquote: false)
10
+ new.serialize(node, indent: indent, blockquote: blockquote)
11
+ end
12
+
13
+ def serialize(node, indent: 0, blockquote: false)
14
+ case node.type
15
+ when Types::DOCUMENT then serialize_document(node)
16
+ when Types::HEADING then serialize_heading(node, indent: indent, blockquote: blockquote)
17
+ when Types::PARAGRAPH then serialize_paragraph(node, indent: indent, blockquote: blockquote)
18
+ when Types::LIST then serialize_list(node, indent: indent, blockquote: blockquote)
19
+ when Types::LIST_ITEM then serialize_list_item(node, indent: indent, blockquote: blockquote)
20
+ when Types::BLOCKQUOTE then serialize_blockquote(node, indent: indent)
21
+ when Types::CODE_BLOCK then serialize_code_block(node, indent: indent, blockquote: blockquote)
22
+ when Types::HRULE then serialize_hrule(indent: indent, blockquote: blockquote)
23
+ else serialize_inline(node)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def serialize_document(node)
30
+ blocks = node.children.map { |c| serialize(c) }
31
+ join_blocks(blocks)
32
+ end
33
+
34
+ def serialize_heading(node, indent:, blockquote:)
35
+ prefix = spaces(indent)
36
+ text = serialize_inline_children(node.children)
37
+ line = "#{'#' * node.level} #{text}"
38
+ wrap(prefix + line, blockquote)
39
+ end
40
+
41
+ def serialize_paragraph(node, indent:, blockquote:)
42
+ prefix = spaces(indent)
43
+ inner = serialize_inline_children(node.children)
44
+ inner_lines = inner.split("\n").map { |l| prefix + l }
45
+ # Hard breaks (two trailing spaces) were captured as soft/hardbreak nodes;
46
+ # join preserved '\n' and treat each line under the same prefix.
47
+ wrap_inline_lines(inner_lines.join("\n"), blockquote)
48
+ end
49
+
50
+ def serialize_list(node, indent:, blockquote:)
51
+ marker = node.ordered? ? nil : "-"
52
+ start = node.ordered? ? (node.attributes[:start] || 1) : nil
53
+ lines = node.children.each_with_index.map do |item, idx|
54
+ num = start ? "#{start + idx}." : marker
55
+ serialize_list_item(item, marker: num, indent: indent, blockquote: blockquote)
56
+ end
57
+ wrap(lines.join("\n"), blockquote)
58
+ end
59
+
60
+ def serialize_list_item(node, indent:, blockquote:, marker:)
61
+ head = marker || "-"
62
+ prefix = spaces(indent)
63
+ marker_col = indent + head.length + 1
64
+ task = node.attributes[:task] ? "[#{node.attributes[:checked] ? 'x' : ' '}] " : ""
65
+
66
+ first = node.children.first
67
+ first_line =
68
+ if first && first.type == Types::PARAGRAPH
69
+ "#{prefix}#{head} #{task}#{first.plain_text}"
70
+ elsif first
71
+ "#{prefix}#{head} #{task}#{serialize(first, indent: marker_col, blockquote: blockquote).strip}"
72
+ else
73
+ "#{prefix}#{head} #{task}"
74
+ end
75
+
76
+ rest = node.children[1..] || []
77
+ rest_lines = rest.map { |c| serialize(c, indent: marker_col, blockquote: blockquote) }
78
+ [first_line, *rest_lines].join("\n")
79
+ end
80
+
81
+ def serialize_blockquote(node, indent:)
82
+ inner = node.children.map { |c| serialize(c) }.join("\n\n")
83
+ inner.lines.map(&:chomp).map { |l| l.strip.empty? ? ">" : "#{spaces(indent)}> #{l}" }.join("\n")
84
+ end
85
+
86
+ def serialize_code_block(node, indent:, blockquote:)
87
+ prefix = spaces(indent)
88
+ if node.fenced?
89
+ fence = "```"
90
+ lang = node.lang ? node.lang : ""
91
+ content = node.literal.to_s.lines.map { |l| prefix + l.chomp }.join("\n")
92
+ wrap("#{prefix}#{fence}#{lang}\n#{content}\n#{prefix}#{fence}", blockquote)
93
+ else
94
+ # Indented code: 4 spaces.
95
+ lines = node.literal.to_s.split("\n").map { |l| prefix + " " + l }
96
+ wrap(lines.join("\n"), blockquote)
97
+ end
98
+ end
99
+
100
+ def serialize_hrule(indent:, blockquote:)
101
+ wrap(spaces(indent) + "---", blockquote)
102
+ end
103
+
104
+ # -- inline -------------------------------------------------------------
105
+
106
+ def serialize_inline(node)
107
+ serialize_inline_children(node.children)
108
+ end
109
+
110
+ def serialize_inline_children(children)
111
+ children.map { |c| serialize_inline_node(c) }.join
112
+ end
113
+
114
+ def serialize_inline_node(node)
115
+ case node.type
116
+ when Types::TEXT then node.value.to_s
117
+ when Types::EMPHASIS then "*#{serialize_inline_children(node.children)}*"
118
+ when Types::STRONG then "**#{serialize_inline_children(node.children)}**"
119
+ when Types::CODE then "`#{node.value}`"
120
+ when Types::LINK then serialize_link(node)
121
+ when Types::IMAGE then serialize_image(node)
122
+ when Types::SOFTBREAK then "\n"
123
+ when Types::HARDBREAK then " \n"
124
+ when Types::DOCUMENT, Types::PARAGRAPH, Types::HEADING
125
+ serialize_inline_children(node.children)
126
+ else node.respond_to?(:value) ? node.value.to_s : ""
127
+ end
128
+ end
129
+
130
+ def serialize_link(node)
131
+ label = serialize_inline_children(node.children)
132
+ title = node.title ? %( "#{node.title}") : ""
133
+ "[#{label}](#{node.destination}#{title})"
134
+ end
135
+
136
+ def serialize_image(node)
137
+ title = node.title ? %( "#{node.title}") : ""
138
+ "![#{node.alt}](#{node.destination}#{title})"
139
+ end
140
+
141
+ # -- helpers ------------------------------------------------------------
142
+
143
+ def spaces(n)
144
+ " " * n
145
+ end
146
+
147
+ # Join top-level blocks with a single blank line between them.
148
+ def join_blocks(blocks)
149
+ blocks.map { |b| b.sub(/\A\n+|\n+\z/, "") }.join("\n\n")
150
+ end
151
+
152
+ # Prefix already-indented block content with a blockquote marker per line.
153
+ def wrap(content, blockquote)
154
+ return content unless blockquote
155
+
156
+ content.lines.map { |l| l.strip.empty? ? ">" : "> #{l}" }.join
157
+ end
158
+
159
+ def wrap_inline_lines(content, blockquote)
160
+ return content unless blockquote
161
+
162
+ content.lines.map { |l| l.strip.empty? ? ">" : "> #{l}" }.join
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MDOM
4
+ VERSION = "0.1.0"
5
+ end
data/lib/mdom.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mdom/version"
4
+ require_relative "mdom/node"
5
+ require_relative "mdom/nodes"
6
+ require_relative "mdom/parser"
7
+ require_relative "mdom/inline_parser"
8
+ require_relative "mdom/serializer"
9
+
10
+ module MDOM
11
+ # Serialize a node (or subtree) back to a Markdown string.
12
+ def self.serialize(node)
13
+ Serializer.serialize(node)
14
+ end
15
+ end
metadata ADDED
@@ -0,0 +1,46 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mdom
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - mdom
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: MDOM parses Markdown into a node tree (block + inline), supports traversal,
13
+ querying and mutation, and serializes back to a Markdown string.
14
+ executables: []
15
+ extensions: []
16
+ extra_rdoc_files: []
17
+ files:
18
+ - lib/mdom.rb
19
+ - lib/mdom/inline_parser.rb
20
+ - lib/mdom/node.rb
21
+ - lib/mdom/nodes.rb
22
+ - lib/mdom/parser.rb
23
+ - lib/mdom/serializer.rb
24
+ - lib/mdom/version.rb
25
+ licenses:
26
+ - MIT
27
+ metadata:
28
+ rubygems_mfa_required: 'true'
29
+ rdoc_options: []
30
+ require_paths:
31
+ - lib
32
+ required_ruby_version: !ruby/object:Gem::Requirement
33
+ requirements:
34
+ - - ">="
35
+ - !ruby/object:Gem::Version
36
+ version: '3.0'
37
+ required_rubygems_version: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '0'
42
+ requirements: []
43
+ rubygems_version: 3.6.7
44
+ specification_version: 4
45
+ summary: 'A Markdown DOM: a self-contained AST for Markdown.'
46
+ test_files: []