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 +7 -0
- data/lib/mdom/inline_parser.rb +185 -0
- data/lib/mdom/node.rb +255 -0
- data/lib/mdom/nodes.rb +214 -0
- data/lib/mdom/parser.rb +285 -0
- data/lib/mdom/serializer.rb +165 -0
- data/lib/mdom/version.rb +5 -0
- data/lib/mdom.rb +15 -0
- metadata +46 -0
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
|
data/lib/mdom/parser.rb
ADDED
|
@@ -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
|
+
""
|
|
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
|
data/lib/mdom/version.rb
ADDED
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: []
|