tty-markdown 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "tty/markdown"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,49 @@
1
+
2
+ TTY::Markdown
3
+ =============
4
+
5
+ **tty-markdown** converts markdown document into a terminal friendly output.
6
+
7
+ ## Examples
8
+
9
+ ### Nested list items
10
+
11
+ - Item 1
12
+ - Item 2
13
+ - Item 3
14
+ - Item 4
15
+ - Item 5
16
+
17
+ ### Quote
18
+
19
+ > Blockquotes are very handy in email to emulate reply text.
20
+ > This line is part of the same quote.
21
+ > *Oh*, you can put **Markdown** into a blockquote.
22
+
23
+ ### Codeblock
24
+
25
+ ```ruby
26
+ class Greeter
27
+ def hello(name)
28
+ puts "Hello #{name}"
29
+ end
30
+ end
31
+ ```
32
+
33
+ ### Table
34
+
35
+ | Tables | Are | Cool |
36
+ |----------|:-------------:|------:|
37
+ | col 1 is | left-aligned | $1600 |
38
+ | col 2 is | centered | $12 |
39
+ | col 3 is | right-aligned | $1 |
40
+
41
+ ### Horizontal line
42
+
43
+ ***
44
+
45
+ ### Link
46
+
47
+ [I'm an inline-style link](https://www.google.com)
48
+
49
+ [I'm an inline-style link with title](https://www.google.com "Google's Homepage")
@@ -0,0 +1,6 @@
1
+ require_relative '../lib/tty-markdown'
2
+
3
+ path = File.join(__dir__, 'example.md')
4
+ out = TTY::Markdown.parse_file(path, colors: 256)
5
+
6
+ puts out
@@ -0,0 +1 @@
1
+ require_relative 'tty/markdown'
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kramdown'
4
+
5
+ require_relative 'markdown/parser'
6
+ require_relative 'markdown/version'
7
+
8
+ module TTY
9
+ module Markdown
10
+ SYMBOLS = {
11
+ arrow: '»',
12
+ bullet: '●',
13
+ bar: '┃',
14
+ diamond: '◈',
15
+ pipe: '│',
16
+ line: '─',
17
+ top_left: '┌',
18
+ top_right: '┐',
19
+ top_center: '┬',
20
+ mid_left: '├',
21
+ mid_right: '┤',
22
+ mid_center: '┼',
23
+ bottom_right: '┘',
24
+ bottom_left: '└',
25
+ bottom_center: '┴'
26
+ }
27
+
28
+ WIN_SYMBOLS = {
29
+ arrow: '->',
30
+ bullet: '*',
31
+ diamond: '*',
32
+ bar: '│',
33
+ pipe: '|',
34
+ line: '─',
35
+ top_left: '+',
36
+ top_right: '+',
37
+ top_center: '+',
38
+ mid_left: '+',
39
+ mid_right: '+',
40
+ mid_center: '+',
41
+ bottom_right: '+',
42
+ bottom_left: '+',
43
+ bottom_center: '+'
44
+ }
45
+
46
+ THEME = {
47
+ em: :italic,
48
+ header: [:cyan, :bold],
49
+ hr: :yellow,
50
+ link: [:blue, :underline],
51
+ list: :yellow,
52
+ strong: [:yellow, :bold],
53
+ table: :blue,
54
+ quote: :yellow,
55
+ }
56
+
57
+ # Parse a markdown string
58
+ #
59
+ # @param [Hash] options
60
+ # @option options [String] :colors
61
+ # a number of colors supported
62
+ # @option options [String] :width
63
+ #
64
+ # @param [String] source
65
+ # the source with markdown
66
+ #
67
+ # @api public
68
+ def parse(source, **options)
69
+ doc = Kramdown::Document.new(source, options)
70
+ Parser.convert(doc.root, doc.options).join
71
+ end
72
+ module_function :parse
73
+
74
+ # Pase a markdown document
75
+ #
76
+ # @api public
77
+ def parse_file(path, **options)
78
+ parse(::File.read(path), options)
79
+ end
80
+ module_function :parse_file
81
+
82
+ def symbols
83
+ @symbols ||= windows? ? WIN_SYMBOLS : SYMBOLS
84
+ end
85
+ module_function :symbols
86
+
87
+ def windows?
88
+ ::File::ALT_SEPARATOR == "\\"
89
+ end
90
+ module_function :windows?
91
+ end # Markdown
92
+ end # TTY
@@ -0,0 +1,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kramdown/converter'
4
+ require 'pastel'
5
+ require 'strings'
6
+ require 'tty-screen'
7
+
8
+ require_relative 'syntax_highlighter'
9
+
10
+ module TTY
11
+ module Markdown
12
+ # Converts a Kramdown::Document tree to a terminal friendly output
13
+ class Parser < Kramdown::Converter::Base
14
+
15
+ def initialize(root, **options)
16
+ super
17
+ @stack = []
18
+ @current_indent = 0
19
+ @indent = options.fetch(:indent, 2)
20
+ @pastel = Pastel.new
21
+ @color_opts = {mode: options[:colors]}
22
+ @width = options.fetch(:width) { TTY::Screen.width }
23
+ @theme = options.fetch(:theme) { TTY::Markdown::THEME }
24
+ end
25
+
26
+ # Invoke an element conversion
27
+ #
28
+ # @api public
29
+ def convert(el, opts = {indent: 0, result: []})
30
+ send("convert_#{el.type}", el, opts)
31
+ end
32
+
33
+ private
34
+
35
+ # Process children of this element
36
+ def inner(el, opts)
37
+ @stack << [el, opts]
38
+ el.children.each_with_index do |inner_el, i|
39
+ options = opts.dup
40
+ options[:parent] = el
41
+ options[:prev] = (i == 0 ? nil : el.children[i - 1])
42
+ options[:index] = i
43
+ convert(inner_el, options)
44
+ end
45
+ @stack.pop
46
+ end
47
+
48
+ def convert_root(el, opts)
49
+ inner(el, opts)
50
+ opts[:result]
51
+ end
52
+
53
+ def convert_header(el, opts)
54
+ level = el.options[:level]
55
+ @current_indent = (level - 1) * @indent # Header determines indentation
56
+ indent = ' ' * (level - 1) * @indent
57
+ styles = Array(@theme[:header]).dup
58
+ styles << :underline if level == 1
59
+ opts[:result] << indent + @pastel.lookup(*styles)
60
+ inner(el, opts)
61
+ opts[:result] << @pastel.lookup(:reset) + "\n"
62
+ end
63
+
64
+ def convert_p(el, opts)
65
+ result_before = @stack.last[1][:result].dup
66
+ indent = ' ' * @current_indent
67
+ if opts[:parent].type != :blockquote
68
+ opts[:result] << indent
69
+ end
70
+
71
+ case opts[:parent].type
72
+ when :li
73
+ bullet = TTY::Markdown.symbols[:bullet]
74
+ index = @stack.last[1][:index] + 1
75
+ symbol = opts[:ordered] ? "#{index}." : bullet
76
+ styles = Array(@theme[:list])
77
+ opts[:result] << @pastel.decorate(symbol, *styles) + ' '
78
+ end
79
+
80
+ inner(el, opts)
81
+
82
+ if opts[:parent].type == :blockquote
83
+ format_blockquote(result_before, opts[:result])
84
+ end
85
+
86
+ unless opts[:result].last.end_with?("\n")
87
+ opts[:result] << "\n"
88
+ end
89
+ end
90
+
91
+ def format_blockquote(result_before, result)
92
+ indent = ' ' * @current_indent
93
+ start_index = result_before.size
94
+ max_index = result.size - 1
95
+ bar_symbol = TTY::Markdown.symbols[:bar]
96
+ styles = Array(@theme[:quote])
97
+ prefix = "#{indent}#{@pastel.decorate(bar_symbol, *styles)} "
98
+
99
+ result.map!.with_index do |str, i|
100
+ if i == start_index
101
+ str.insert(0, prefix)
102
+ end
103
+
104
+ if i >= start_index && str.include?("\n")
105
+ str.lines.map! do |line|
106
+ if line != str.lines.last || i < max_index
107
+ line.insert(-1, prefix)
108
+ else
109
+ line
110
+ end
111
+ end
112
+ else
113
+ str
114
+ end
115
+ end
116
+ end
117
+
118
+ def convert_text(el, opts)
119
+ text = el.value
120
+ opts[:result] << text
121
+ end
122
+
123
+ def convert_strong(el, opts)
124
+ styles = Array(@theme[:strong])
125
+ opts[:result] << @pastel.lookup(*styles)
126
+ inner(el, opts)
127
+ opts[:result] << @pastel.lookup(:reset)
128
+ end
129
+
130
+ def convert_em(el, opts)
131
+ styles = Array(@theme[:em])
132
+ opts[:result] << @pastel.lookup(*styles)
133
+ inner(el, opts)
134
+ opts[:result] << @pastel.lookup(:reset)
135
+ end
136
+
137
+ def convert_blank(el, opts)
138
+ opts[:result] << "\n"
139
+ end
140
+
141
+ def convert_smart_quote(el, opts)
142
+ inner(el, opts)
143
+ end
144
+
145
+ def convert_codespan(el, opts)
146
+ raw_code = el.value
147
+ highlighted = SyntaxHighliter.highlight(raw_code, @color_opts)
148
+ code = highlighted.split("\n").map.with_index do |line, i|
149
+ if i == 0 # first line
150
+ line
151
+ else
152
+ line.insert(0, ' ' * @current_indent)
153
+ end
154
+ end
155
+ opts[:result] << code.join("\n")
156
+ end
157
+
158
+ def convert_blockquote(el, opts)
159
+ inner(el, opts)
160
+ end
161
+
162
+ def convert_ul(el, opts)
163
+ @current_indent += @indent unless opts[:parent].type == :root
164
+ inner(el, opts)
165
+ @current_indent -= @indent unless opts[:parent].type == :root
166
+ end
167
+ alias convert_ol convert_ul
168
+
169
+ def convert_li(el, opts)
170
+ if opts[:parent].type == :ol
171
+ opts[:ordered] = true
172
+ end
173
+ inner(el, opts)
174
+ end
175
+
176
+ def convert_table(el, opts)
177
+ opts[:alignment] = el.options[:alignment]
178
+
179
+ result = opts[:result]
180
+ opts[:result] = []
181
+ data = []
182
+
183
+ el.children.each do |container|
184
+ container.children.each do |row|
185
+ data_row = []
186
+ data << data_row
187
+ row.children.each do |cell|
188
+ opts[:result] = []
189
+ cell_data = inner(cell, opts)
190
+ data_row << cell_data[1][:result]
191
+ end
192
+ end
193
+ end
194
+
195
+ opts[:result] = result
196
+ opts[:table_data] = data
197
+
198
+ inner(el, opts)
199
+ end
200
+
201
+ def convert_thead(el, opts)
202
+ indent = ' ' * @current_indent
203
+ table_data = opts[:table_data]
204
+
205
+ opts[:result] << indent
206
+ opts[:result] << border(table_data, :top)
207
+ opts[:result] << "\n"
208
+ inner(el, opts)
209
+ end
210
+
211
+ # Render horizontal border line
212
+ #
213
+ # @param [Array[Array[String]]] table_data
214
+ # table rows and cells
215
+ # @param [Symbol] location
216
+ # location out of :top, :mid, :bottom
217
+ #
218
+ # @return [String]
219
+ #
220
+ # @api private
221
+ def border(table_data, location)
222
+ symbols = TTY::Markdown.symbols
223
+ result = []
224
+ result << symbols[:"#{location}_left"]
225
+ max_widths(table_data).each.with_index do |width, i|
226
+ result << symbols[:"#{location}_center"] if i != 0
227
+ result << (symbols[:line] * (width + 2))
228
+ end
229
+ result << symbols[:"#{location}_right"]
230
+ styles = Array(@theme[:table])
231
+ @pastel.decorate(result.join, *styles)
232
+ end
233
+
234
+ def convert_tbody(el, opts)
235
+ indent = ' ' * @current_indent
236
+ table_data = opts[:table_data]
237
+
238
+ opts[:result] << indent
239
+ if opts[:prev] && opts[:prev].type == :thead
240
+ opts[:result] << border(table_data, :mid)
241
+ else
242
+ opts[:result] << border(table_data, :top)
243
+ end
244
+ opts[:result] << "\n"
245
+
246
+ inner(el, opts)
247
+
248
+ opts[:result] << indent
249
+ opts[:result] << border(table_data, :bottom)
250
+ opts[:result] << "\n"
251
+ end
252
+
253
+ def convert_tfoot(el, opts)
254
+ innert(el, opts)
255
+ end
256
+
257
+ def convert_tr(el, opts)
258
+ indent = ' ' * @current_indent
259
+ pipe = TTY::Markdown.symbols[:pipe]
260
+ styles = Array(@theme[:table])
261
+ table_data = opts[:table_data]
262
+
263
+ if opts[:prev] && opts[:prev].type == :tr
264
+ opts[:result] << indent
265
+ opts[:result] << border(table_data, :mid)
266
+ opts[:result] << "\n"
267
+ end
268
+
269
+ opts[:result] << indent + @pastel.decorate("#{pipe} ", *styles)
270
+ inner(el, opts)
271
+ opts[:result] << "\n"
272
+ end
273
+
274
+ def convert_td(el, opts)
275
+ pipe = TTY::Markdown.symbols[:pipe]
276
+ styles = Array(@theme[:table])
277
+ table_data = opts[:table_data]
278
+ result = opts[:result]
279
+ opts[:result] = []
280
+
281
+ inner(el, opts)
282
+
283
+ column = find_column(table_data, opts[:result])
284
+ width = max_width(table_data, column)
285
+ alignment = opts[:alignment][column]
286
+ align_opts = alignment == :default ? {} : {direction: alignment}
287
+
288
+ result << Strings.align(opts[:result].join, width, align_opts) <<
289
+ " #{@pastel.decorate(pipe, *styles)} "
290
+ end
291
+
292
+ def find_column(table_data, cell)
293
+ table_data.each do |row|
294
+ row.size.times do |col|
295
+ return col if row[col] == cell
296
+ end
297
+ end
298
+ end
299
+
300
+ def max_width(table_data, col)
301
+ table_data.map { |row| Strings.sanitize(row[col].join).length }.max
302
+ end
303
+
304
+ def max_widths(table_data)
305
+ table_data.first.each_with_index.reduce([]) do |acc, (*, col)|
306
+ acc << max_width(table_data, col)
307
+ acc
308
+ end
309
+ end
310
+
311
+ def convert_hr(el, opts)
312
+ indent = ' ' * @current_indent
313
+ symbols = TTY::Markdown.symbols
314
+ width = @width - (indent.length+1) * 2
315
+ styles = Array(@theme[:hr])
316
+ line = symbols[:diamond] + symbols[:line] * width + symbols[:diamond]
317
+
318
+ opts[:result] << indent
319
+ opts[:result] << @pastel.decorate(line, *styles)
320
+ opts[:result] << "\n"
321
+ end
322
+
323
+ def convert_a(el, opts)
324
+ symbols = TTY::Markdown.symbols
325
+ styles = Array(@theme[:link])
326
+ if el.children.size == 1 && el.children[0].type == :text
327
+ opts[:result] << @pastel.decorate(el.attr['href'], *styles)
328
+ else
329
+ if el.attr['title']
330
+ opts[:result] << el.attr['title']
331
+ else
332
+ inner(el, opts)
333
+ end
334
+ opts[:result] << " #{symbols[:arrow]} "
335
+ opts[:result] << @pastel.decorate(el.attr['href'], *styles)
336
+ opts[:result] << "\n"
337
+ end
338
+ end
339
+
340
+ def convert_footnote(*)
341
+ warning("Footnotes are not supported")
342
+ end
343
+
344
+ def convert_raw(*)
345
+ warning("Raw content is not supported")
346
+ end
347
+
348
+ def convert_img(*)
349
+ warning("Images are not supported")
350
+ end
351
+ end # Parser
352
+ end # Markdown
353
+ end # TTY