tty-markdown 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.
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