raindown 0.2.2 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d0e77b47d4bdedcda5ace520aff8dabc470b3d49a9d7f2dac83b43d23d0928c2
4
- data.tar.gz: 0e26371c232241abfcfbaec62a09eb89445299fdd5e4d67273a9c0839bb1982a
3
+ metadata.gz: 17711b42b7bfd45766f0ad743b58035648b99394031bde5a420e92f01918f8a3
4
+ data.tar.gz: 94753448785a4825b3e2745d0690db75d69b052d57c3ca2e185793b427fedbef
5
5
  SHA512:
6
- metadata.gz: 917089b01769a65e0b96d645613f182fdbd7212760823293ed87dd1581071751f53548bdc84d363c40e5a50d4c4941c10f6a409ded7dd2c5687708581dcd9213
7
- data.tar.gz: 1ae3a21d68f7bb6fd7042e371cec3352eb7e1781855dcf3a5c92b80c1024a628481c2a3f159be9ee6fb1769592169640f5d767989aadf29bfbadba76ab610849
6
+ metadata.gz: be61212919255c9018253757f33d02e6cae3ec7811276791fe06bfbdaad748ffe4f47abeac8aad807d5862a7b2bcb493a2d53bebfd30f9abcb820c7faffaf438
7
+ data.tar.gz: 50488661cf504f7fa1fc9963a9aff7a6ebefb755ba46e2b0e63413133ee4e5781e9d9b94f0ba7c3965d7759b453d95ea7254713cf5e5dce73df2e6299de89b71
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'drawing'
4
+
5
+ module Raindown
6
+ class Chart < Node
7
+ # Horizontal bars, with negative values or a minus column left of a line:
8
+ # Jan ████████████▍ 1,240 a1b2c3d -38 ▕████│███████████▍ +142
9
+ class Bars < Drawing
10
+ LEFT = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'].freeze
11
+ # Negative bars fill a cell from the right, which only has eighths and halves.
12
+ RIGHT = ['', '▕', '▕', '▐', '▐', '▐', FULL, FULL].freeze
13
+
14
+ def lines
15
+ measure
16
+ values = value_texts
17
+ minuses = minus_texts
18
+ @labels.each_index.map { |index| "#{label_text(@labels[index])}#{minuses[index]}#{bar_line(index)} #{values[index]}" }
19
+ end
20
+
21
+ private
22
+
23
+ # How many characters a value takes, and how many the bars take either side of the line.
24
+ def measure
25
+ low, high = range
26
+ @scale = @width / (high - low).to_f
27
+ @left = cells(-low * @scale)
28
+ @right = cells(high * @scale)
29
+ @line = low.negative? ? '│' : ''
30
+ end
31
+
32
+ # Each minus value lined up left of its bar, or nothing without a minus column.
33
+ def minus_texts
34
+ return Array.new(@labels.length, '') unless @minus
35
+
36
+ aligned(@below.map { "-#{number_text(it)}" }).map { "#{it} " }
37
+ end
38
+
39
+ # Each value, lined up, with a + when a minus column is charted against it, whose values go left of the bars.
40
+ def value_texts
41
+ aligned(@minus ? @above.map { "+#{number_text(it)}" } : @values.map { number_text(it) })
42
+ end
43
+
44
+ def aligned(texts)
45
+ texts.map { it.rjust(texts.map(&:length).max) }
46
+ end
47
+
48
+ def bar_line(index)
49
+ negative = @below[index].positive? ? bar(@below[index] * @scale, RIGHT, from: :right) : ''
50
+ positive = @above[index].positive? ? bar(@above[index] * @scale, LEFT) : ''
51
+
52
+ "#{negative.rjust(@left)}#{@line}#{pad(positive, @right)}"
53
+ end
54
+
55
+ def label_text(label)
56
+ label_width = @labels.map { display_width(it) }.max
57
+ label_width.zero? ? '' : "#{pad(label, label_width)} "
58
+ end
59
+
60
+ # A bar of full cells and an eighth, where any value above zero shows.
61
+ def bar(length, partials, from: :left)
62
+ eighths = (length * 8).round.clamp(1..)
63
+ full, part = eighths.divmod(8)
64
+
65
+ from == :left ? (FULL * full) + partials[part] : partials[part] + (FULL * full)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../node'
4
+ require_relative 'bars'
5
+ require_relative 'columns'
6
+ require_relative 'line'
7
+ require_relative 'points'
8
+ require_relative 'sparkline'
9
+
10
+ module Raindown
11
+ # The node behind chart:, a chart in block characters of the data it's given, written into the Markdown as code:
12
+ # chart: 'data/downloads.csv' chart: stats format: 'columns' chart: '3 5 -2 8'
13
+ # The data is a CSV, TSV, JSON or YAML file relative to the project, numbers, or a variable with either, or with
14
+ # data of its own like a list or a hash. Labeled data draws as bars and numbers alone as a sparkline,
15
+ # unless format: is 'bars', 'columns', 'line' or 'sparkline'. Negative values go below a line, or left of it for bars.
16
+ # A project can draw charts its own way with a Chart node in .raindown, like "class Chart < Raindown::Chart".
17
+ class Chart < Node
18
+ FORMATS = { 'bars' => Bars, 'columns' => Columns, 'line' => Line, 'sparkline' => Sparkline }.freeze
19
+
20
+ # Label and value are the columns to chart, by name or index, and minus a column to chart against the values.
21
+ # Width is in characters, for bars and lines, and height in lines, for columns and lines.
22
+ def initialize(data = nil, format: nil, label: nil, value: nil, minus: nil, width: 40, height: 8) # rubocop:disable Metrics/ParameterLists -- each is a prop
23
+ @data = data
24
+ @format = format
25
+ @label = label
26
+ @value = value
27
+ @minus = minus
28
+ @width = width
29
+ @height = height
30
+ end
31
+
32
+ # The data is read every time, so the chart follows changes to its file.
33
+ def replace(metadata: NoMetadata)
34
+ points = points(metadata:)
35
+ return '' if points.empty?
36
+
37
+ "```\n#{draw(points).map(&:rstrip).join("\n")}\n```"
38
+ rescue Error => e
39
+ "Can't chart #{@data.inspect}: #{e.message}"
40
+ end
41
+
42
+ # Each value with its label, or nil for numbers alone.
43
+ def points(metadata: NoMetadata)
44
+ root = metadata.respond_to?(:root) ? metadata.root : Dir.pwd
45
+ Points.new(label: @label, value: @value, minus: @minus, root:).from(@data)
46
+ end
47
+
48
+ def draw(points)
49
+ format = @format || (points.any?(&:first) ? 'bars' : 'sparkline')
50
+ drawing = FORMATS[format] || raise(Error, "format: is #{FORMATS.keys.join(', ')}, not #{format.inspect}")
51
+
52
+ drawing.new(points, width: @width, height: @height).lines
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'drawing'
4
+
5
+ module Raindown
6
+ class Chart < Node
7
+ # Vertical columns from a line at zero, with negative values or a minus column below it,
8
+ # the largest and smallest values on the left and labels below.
9
+ # 2,210 ┤ ▄▄▄
10
+ # │ ███ ███
11
+ # 0 ┼─────────
12
+ # Jan Feb
13
+ class Columns < Drawing
14
+ def lines
15
+ low, high = range
16
+ @slot = [3, *@labels.map { display_width(it) }].max
17
+ above, below = grid(@height / (high - low).to_f, slot: @slot, gap: 1)
18
+ top, bottom = ticks
19
+
20
+ [*axis(above, top, at: 0), baseline, *axis(below, bottom, at: below.length - 1), *label_line]
21
+ end
22
+
23
+ private
24
+
25
+ # The largest value at the top, and the smallest at the bottom when it's below zero.
26
+ def ticks
27
+ top = @above.max.positive? ? number_text(@above.max) : ''
28
+ bottom = @below.max.positive? ? number_text(-@below.max) : ''
29
+ @tick_width = [top, bottom, '0'].map(&:length).max
30
+
31
+ [top, bottom]
32
+ end
33
+
34
+ # Rows after the axis, with the tick on the row at its index.
35
+ def axis(rows, tick, at:)
36
+ rows.each_with_index.map do |row, index|
37
+ index == at && !tick.empty? ? "#{tick.rjust(@tick_width)} ┤ #{row}" : "#{' ' * @tick_width} │ #{row}"
38
+ end
39
+ end
40
+
41
+ def baseline
42
+ "#{'0'.rjust(@tick_width)} ┼#{'─' * ((@values.length * (@slot + 1)) + 1)}"
43
+ end
44
+
45
+ def label_line
46
+ return [] if @labels.all?(&:empty?)
47
+
48
+ ["#{' ' * (@tick_width + 3)}#{@labels.map { center(it, @slot) }.join(' ')}"]
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'unicode/display_width'
4
+
5
+ require_relative '../node'
6
+
7
+ module Raindown
8
+ class Chart < Node
9
+ class Drawing
10
+ FULL = '█'
11
+ # Columns fill a cell from the bottom, and negative columns from the top, which only has eighths and halves.
12
+ LOWER = ['', '▁', '▂', '▃', '▄', '▅', '▆', '▇'].freeze
13
+ UPPER = ['', '▔', '▔', '▀', '▀', '▀', FULL, FULL].freeze
14
+ # Float error, so a value of exactly 3 cells doesn't reach into a 4th.
15
+ EPSILON = 1e-9
16
+
17
+ # Values go above zero or right of its line, and negative values below or left of it. With a minus column,
18
+ # each point is [label, value, minus], and its value goes above and its minus below, whatever their signs.
19
+ def initialize(points, width:, height:)
20
+ @labels = points.map { it.first.to_s }
21
+ @values = points.map { it[1] }
22
+ @minus = points.map { it[2] } if points.any? { it.length > 2 }
23
+ @above = @minus ? @values.map(&:abs) : @values.map { it.clamp(0..) }
24
+ @below = @minus ? @minus.map(&:abs) : @values.map { (-it).clamp(0..) }
25
+ @width = width
26
+ @height = height
27
+ end
28
+
29
+ private
30
+
31
+ # How far below and above zero the chart goes, which always includes zero, so bars and columns start from it.
32
+ def range
33
+ low = -@below.max
34
+ high = @above.max
35
+
36
+ [low, low == high ? 1 : high]
37
+ end
38
+
39
+ # The rows of columns above and below zero, top to bottom, each column in the middle of its slot.
40
+ def grid(scale, slot:, gap:)
41
+ low, high = range
42
+
43
+ [grid_rows(cells(high * scale).downto(1), @above, scale, LOWER, slot:, gap:),
44
+ grid_rows(1.upto(cells(-low * scale)), @below, scale, UPPER, slot:, gap:)]
45
+ end
46
+
47
+ # Each row by how many cells it is from zero, for the values above it or below it.
48
+ def grid_rows(rows, values, scale, partials, slot:, gap:) # rubocop:disable Metrics/ParameterLists -- a row of the grid
49
+ rows.map do |row|
50
+ values.map { |value| center(cell((value * scale) - row + 1, partials) * [slot, 3].min, slot) }.join(' ' * gap)
51
+ end
52
+ end
53
+
54
+ # How much of a cell a column fills, by eighths, where any value above zero shows.
55
+ def cell(level, partials)
56
+ return ' ' if level <= EPSILON
57
+
58
+ eighths = (level.clamp(..1) * 8).round.clamp(1..)
59
+ eighths == 8 ? FULL : partials[eighths]
60
+ end
61
+
62
+ def cells(length)
63
+ (length - EPSILON).ceil.clamp(0..)
64
+ end
65
+
66
+ # Whole numbers with thousands separators, and others to two decimal places.
67
+ def number_text(value)
68
+ value = value.round(2)
69
+ value = value.to_i if value.is_a?(Float) && value == value.to_i
70
+ whole, decimal = value.abs.to_s.split('.')
71
+
72
+ "#{'-' if value.negative?}#{whole.reverse.scan(/\d{1,3}/).join(',').reverse}#{".#{decimal}" if decimal}"
73
+ end
74
+
75
+ def display_width(text)
76
+ Unicode::DisplayWidth.of(text)
77
+ end
78
+
79
+ def pad(text, width)
80
+ text + (' ' * (width - display_width(text)))
81
+ end
82
+
83
+ def center(text, width)
84
+ pad((' ' * ((width - display_width(text)) / 2)) + text, width)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'drawing'
4
+
5
+ module Raindown
6
+ class Chart < Node
7
+ # A line through the values in braille, where each character is a grid of dots 2 wide and 4 high,
8
+ # with the highest and lowest values on the left, labels below and negative values below a line at zero.
9
+ # With a minus column, its values are a second line below zero.
10
+ # 4 ┤ ⠑⡄ ⢀⠎
11
+ # 0 ┼──⠈⠢⡀──⡰⠁─
12
+ # -4 ┤ ⠘⢄⠎
13
+ # Jan Mar
14
+ class Line < Drawing
15
+ BRAILLE = 0x2800
16
+ # The bit of each dot in a braille character, by its row and then its column.
17
+ DOTS = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]].freeze
18
+
19
+ def lines
20
+ @series = @minus ? [@above, @below.map(&:-@)] : [@values]
21
+ @low, @high = bounds
22
+ @cells = Array.new(@height) { Array.new(@width, 0) }
23
+ @series.each { plot_series(it) }
24
+
25
+ [*axis, *label_line]
26
+ end
27
+
28
+ private
29
+
30
+ def plot_series(values)
31
+ dots = values.each_index.map { |index| [x(index), y(values[index])] }
32
+ dots.each_cons(2) { |from, to| plot_line(*from, *to) }
33
+ plot(*dots.first) if dots.one?
34
+ end
35
+
36
+ # The lowest and highest values, which include zero when there are negative values, so they go below its line.
37
+ def bounds
38
+ low, high = @series.flatten.minmax
39
+ high = [high, 0].max if low.negative?
40
+
41
+ [low, high]
42
+ end
43
+
44
+ def x(index)
45
+ return 0 if @values.one?
46
+
47
+ (index * ((@width * 2) - 1).to_f / (@values.length - 1)).round
48
+ end
49
+
50
+ def y(value)
51
+ return (@height * 4) - 1 if @high == @low
52
+
53
+ ((@high - value) * ((@height * 4) - 1).to_f / (@high - @low)).round
54
+ end
55
+
56
+ # The dots between two dots, a step at a time along whichever of across or down is longer.
57
+ def plot_line(x_from, y_from, x_to, y_to)
58
+ steps = [(x_to - x_from).abs, (y_to - y_from).abs, 1].max
59
+ 0.upto(steps) { |step| plot(between(x_from, x_to, step, steps), between(y_from, y_to, step, steps)) }
60
+ end
61
+
62
+ def between(from, to, step, steps)
63
+ from + ((to - from) * step.to_f / steps).round
64
+ end
65
+
66
+ def plot(x, y) # rubocop:disable Naming/MethodParameterName -- a dot's coordinates
67
+ @cells[y / 4][x / 2] |= DOTS[y % 4][x % 2]
68
+ end
69
+
70
+ # Each row of braille after the axis, with the highest value at the top, the lowest at the bottom,
71
+ # and zero on the line through the negative values' row.
72
+ def axis
73
+ @ticks = ticks
74
+ @tick_width = @ticks.values.map(&:length).max
75
+
76
+ @cells.each_with_index.map do |cells, row|
77
+ "#{@ticks[row].to_s.rjust(@tick_width)} #{mark(row)}#{cells.map { cell(it, row) }.join}"
78
+ end
79
+ end
80
+
81
+ # The values on the axis by their row, where the highest wins a row it shares with the lowest.
82
+ def ticks
83
+ ticks = { (@height - 1) => number_text(@low) }
84
+ ticks[0] = number_text(@high) unless @high == @low
85
+ ticks[zero_row] ||= '0' if zero_row
86
+ ticks
87
+ end
88
+
89
+ def mark(row)
90
+ return '┼─' if row == zero_row
91
+
92
+ @ticks[row] ? '┤ ' : '│ '
93
+ end
94
+
95
+ def zero_row
96
+ y(0) / 4 if @low.negative?
97
+ end
98
+
99
+ def cell(bits, row)
100
+ return (BRAILLE + bits).chr(Encoding::UTF_8) unless bits.zero?
101
+
102
+ row == zero_row ? '─' : ' '
103
+ end
104
+
105
+ # The labels under their values, the first and last ones first, leaving out any that would touch another.
106
+ def label_line
107
+ return [] if @labels.all?(&:empty?)
108
+
109
+ line = placed_labels.sort.reduce(+'') { |text, (from, _, label)| text << (' ' * (from - display_width(text))) << label }
110
+ ["#{' ' * (@tick_width + 3)}#{line}"]
111
+ end
112
+
113
+ # Each label that fits, with where it starts and ends.
114
+ def placed_labels
115
+ [0, @labels.length - 1, *(1...(@labels.length - 1))].uniq.each_with_object([]) do |index, placed|
116
+ span = label_span(index)
117
+ placed << [*span, @labels[index]] if placed.none? { |from, to| span[0] <= to + 1 && span[1] >= from - 1 }
118
+ end
119
+ end
120
+
121
+ # Where a label starts and ends, centered on its value's column and kept inside the chart.
122
+ def label_span(index)
123
+ width = display_width(@labels[index])
124
+ from = ((x(index) / 2) - (width / 2)).clamp(0, [@width - width, 0].max)
125
+
126
+ [from, from + width - 1]
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'csv'
4
+ require 'json'
5
+ require 'yaml'
6
+
7
+ require_relative '../node'
8
+
9
+ module Raindown
10
+ class Chart < Node
11
+ class Error < StandardError; end
12
+
13
+ # A chart's data as points, each value with its label, or with nil for numbers alone.
14
+ # Label and value are the columns to chart, by name or index, which default to the first column
15
+ # and the first column of numbers after it. Rows without a number are left out.
16
+ # Minus is a column of numbers to chart against the values, like lines deleted against lines added,
17
+ # which makes each point [label, value, minus].
18
+ class Points
19
+ NUMBER = /\A[+-]?(?:\d{1,3}(?:,\d{3})+|\d+)?(?:\.\d+)?\z/
20
+
21
+ def initialize(label: nil, value: nil, minus: nil, root: Dir.pwd)
22
+ @label = label
23
+ @value = value
24
+ @minus = minus
25
+ @root = root
26
+ end
27
+
28
+ def from(data)
29
+ case data
30
+ when String then from_string(data)
31
+ when Numeric then numbers([data])
32
+ when Hash then from_table(data.to_a, header: false)
33
+ when Array then from_list(data)
34
+ else raise Error, 'there is no data'
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ # A file's path, relative to the root, or numbers like '3 5 -2 8' or '3, 5, -2, 8'.
41
+ def from_string(string)
42
+ numbers = string.strip.split(/[\s,]+/)
43
+ return numbers(numbers) if numbers.any? && numbers.all? { number(it) }
44
+
45
+ path = File.expand_path(string, @root)
46
+ raise Error, 'no such file' unless File.file?(path)
47
+
48
+ from(read(path))
49
+ end
50
+
51
+ def read(path)
52
+ text = File.read(path, encoding: Encoding::UTF_8)
53
+
54
+ case File.extname(path).downcase
55
+ when '.csv' then CSV.parse(text, skip_blanks: true)
56
+ when '.tsv' then CSV.parse(text, col_sep: "\t", skip_blanks: true)
57
+ when '.json' then JSON.parse(text)
58
+ when '.yml', '.yaml' then YAML.safe_load(text)
59
+ else raise Error, 'only CSV, TSV, JSON and YAML files can be charted'
60
+ end
61
+ rescue CSV::MalformedCSVError, JSON::ParserError, Psych::SyntaxError => e
62
+ raise Error, e.message
63
+ end
64
+
65
+ # Numbers, rows of hashes like JSON or YAML, or rows of cells like CSV.
66
+ def from_list(list)
67
+ return numbers(list) if list.all? { number(it) }
68
+ return from_table(hash_rows(list), header: true) if list.all?(Hash)
69
+ return from_table(list, header: header?(list.first)) if list.all?(Array)
70
+
71
+ raise Error, 'it needs numbers, or rows with a number in each'
72
+ end
73
+
74
+ # Numbers alone, which have no column to chart against them.
75
+ def numbers(list)
76
+ raise Error, 'minus: needs a table, with a column of numbers to chart against the values' if @minus
77
+
78
+ list.map { [nil, number(it)] }
79
+ end
80
+
81
+ # Rows of hashes as a table, with their keys as its header.
82
+ def hash_rows(rows)
83
+ keys = rows.flat_map(&:keys).uniq
84
+ [keys.map(&:to_s), *rows.map { |row| row.values_at(*keys) }]
85
+ end
86
+
87
+ # A first row without numbers names the columns, like "month,downloads".
88
+ def header?(row)
89
+ row.none? { number(it) }
90
+ end
91
+
92
+ def from_table(rows, header:)
93
+ names = header ? rows.first.map(&:to_s) : []
94
+ rows = rows.drop(1) if header
95
+ label, value, minus = columns(rows, names)
96
+
97
+ rows.filter_map do |row|
98
+ number = number(row[value])
99
+ [label && row[label].to_s, number, *(number(row[minus]) || 0 if minus)] if number
100
+ end
101
+ end
102
+
103
+ # The label, value and minus columns' indexes, where a single column of numbers has no labels.
104
+ def columns(rows, names)
105
+ minus = column(@minus, names) if @minus
106
+ value = column(@value, names) if @value
107
+ label = @label ? column(@label, names) : first_label(rows, [value, minus])
108
+ value ||= first_value(rows, [label, minus])
109
+ raise Error, 'there is no column of numbers' unless value
110
+
111
+ [label, value, minus]
112
+ end
113
+
114
+ # The first column that isn't taken, when there's more than one.
115
+ def first_label(rows, taken)
116
+ count = rows.map(&:length).max.to_i
117
+ (0...count).find { !taken.include?(it) } if count > 1
118
+ end
119
+
120
+ # The first column of numbers that isn't taken.
121
+ def first_value(rows, taken)
122
+ (0...rows.map(&:length).max.to_i).find { |index| !taken.include?(index) && rows.any? { number(it[index]) } }
123
+ end
124
+
125
+ def column(name, names)
126
+ return name if name.is_a?(Integer)
127
+
128
+ names.index(name.to_s) || raise(Error, "there is no #{name.inspect} column")
129
+ end
130
+
131
+ def number(value)
132
+ return value if value.is_a?(Numeric)
133
+ return unless value.is_a?(String) && value.strip.match?(NUMBER) && value.match?(/\d/)
134
+
135
+ text = value.strip.delete(',')
136
+ text.include?('.') ? Float(text) : Integer(text, 10)
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'drawing'
4
+
5
+ module Raindown
6
+ class Chart < Node
7
+ # A line of numbers from lowest to highest, or columns a cell high with negative values or a minus column
8
+ # below a line.
9
+ # ▁▃▅█▂
10
+ class Sparkline < Drawing
11
+ SPARKS = %w[▁ ▂ ▃ ▄ ▅ ▆ ▇ █].freeze
12
+
13
+ def lines
14
+ return [@above.map { spark(it, *@above.minmax) }.join] if @below.none?(&:positive?)
15
+
16
+ above, below = grid(1.0 / [@above.max, @below.max].max, slot: 1, gap: 0)
17
+ [*above, '─' * @values.length, *below]
18
+ end
19
+
20
+ private
21
+
22
+ def spark(value, low, high)
23
+ SPARKS[high == low ? 0 : ((value - low) * 7.0 / (high - low)).round]
24
+ end
25
+ end
26
+ end
27
+ end
data/lib/raindown/cli.rb CHANGED
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'fileutils'
3
4
  require 'trees'
4
5
  require_relative '../raindown'
6
+ require_relative '../version'
5
7
  require_relative 'rewriter'
6
8
  require_relative 'watcher'
7
9
 
@@ -11,9 +13,13 @@ module Raindown
11
13
 
12
14
  USAGE = <<~TEXT
13
15
  Usage: raindown FILE [options]
16
+ raindown init [DIRECTORY]
14
17
 
15
18
  Renders FILE to HTML, then re-renders it every time it is saved.
16
19
 
20
+ Commands:
21
+ init [DIRECTORY] Create a .raindown folder for the project's config and nodes (default: current directory)
22
+
17
23
  Options:
18
24
  -o, --output PATH Write output to PATH instead of FILE.html beside FILE
19
25
  --once Render once without watching, printing to stdout unless -o is given
@@ -22,8 +28,21 @@ module Raindown
22
28
  --bare Allow bare directives like :toc in Markdown files
23
29
  -x, --execute Run endpoint curl snippets that have no output yet
24
30
  -h, --help Show this message
31
+ -v, --version Show the version
32
+
33
+ Set RAINDOWN_DEBUG=1 to show where an error happened.
25
34
  TEXT
26
35
 
36
+ CONFIG = <<~YAML
37
+ # Raindown project settings. See https://raindown.dev/docs/config
38
+ #
39
+ # Show directives as HTML comments rather than in bold:
40
+ # hide_directives: true
41
+ #
42
+ # Leave code blocks unhighlighted, so the browser can highlight them instead:
43
+ # highlight_code: false
44
+ YAML
45
+
27
46
  OPTIONS = { output: ['-o :output', '--output :output'], bare: ['--bare'], execute: ['-x', '--execute'],
28
47
  once: ['--once'], stdin: ['--stdin'] }.freeze
29
48
 
@@ -50,11 +69,34 @@ module Raindown
50
69
  end
51
70
  end
52
71
 
53
- # The :path line would match a help flag as the file, so help is checked first.
72
+ # The :path line would match help, version and init as the file, so they're checked first.
54
73
  def self.run(argv)
55
74
  abort(USAGE) if argv.empty? || argv.intersect?(%w[-h --help])
75
+ return puts(VERSION) if argv.intersect?(%w[-v --version])
76
+ return init(argv[1]) if argv.first == 'init' && argv.size <= 2
56
77
 
57
78
  super || abort("Unknown arguments: #{argv.join(' ')}\n\n#{USAGE}")
79
+ rescue StandardError => e
80
+ report(e, path: argv.first)
81
+ end
82
+
83
+ # Shows an error in a file as one line, rather than a backtrace, unless RAINDOWN_DEBUG is set.
84
+ def self.report(error, path:)
85
+ raise error if ENV['RAINDOWN_DEBUG']
86
+
87
+ abort("raindown: #{path}: #{error.message}")
88
+ end
89
+
90
+ # Creates the .raindown folder that marks a project's root and holds its config.yml and nodes.
91
+ def self.init(directory = nil)
92
+ folder = File.join(directory || Dir.pwd, Nodes::FOLDER)
93
+ config = File.join(folder, Config::FILE)
94
+ return puts("#{config} already exists") if File.exist?(config)
95
+
96
+ FileUtils.mkdir_p(folder)
97
+ File.write(config, CONFIG)
98
+ puts "Created #{config}"
99
+ puts 'Put your own nodes in the same folder. See https://raindown.dev/docs/nodes'
58
100
  end
59
101
 
60
102
  class << self
@@ -72,7 +114,7 @@ module Raindown
72
114
  def render_stdin(path, output = nil, bare: false, **)
73
115
  Nodes.load(path)
74
116
  write(output, Raindown.render(markdown: $stdin.read, metadata: Pages.for(path), bare: bare || BareDirectives.file?(path),
75
- hide_directives: Config.for(path)[:hide_directives]))
117
+ **Config.render_options(path)))
76
118
  end
77
119
 
78
120
  def watch(path, output = nil, bare: false, execute: false)
@@ -5,9 +5,10 @@ require 'yaml'
5
5
  module Raindown
6
6
  # A project's settings, from config.yml in its .raindown folder:
7
7
  # hide_directives: true # Directives become HTML comments rather than bold text in the HTML.
8
+ # highlight_code: false # Code blocks aren't highlighted, so the browser can highlight them instead.
8
9
  module Config
9
10
  FILE = 'config.yml'
10
- DEFAULTS = { hide_directives: false }.freeze
11
+ DEFAULTS = { hide_directives: false, highlight_code: true }.freeze
11
12
 
12
13
  module_function
13
14
 
@@ -19,5 +20,10 @@ module Raindown
19
20
 
20
21
  DEFAULTS.merge(YAML.safe_load_file(file, symbolize_names: true) || {})
21
22
  end
23
+
24
+ # The settings for rendering a file, like Raindown.render(**Config.render_options(path)).
25
+ def render_options(path)
26
+ self.for(path).slice(:hide_directives, :highlight_code)
27
+ end
22
28
  end
23
29
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'erb'
3
+ require 'cgi/escape'
4
4
 
5
5
  module Raindown
6
6
  # Content directives, like a node's directive and end tag or an endpoint's, are kept in the HTML:
@@ -22,7 +22,7 @@ module Raindown
22
22
  def render(html, hide: false)
23
23
  html.gsub(MARKER) do
24
24
  text = ::Regexp.last_match(1)
25
- hide ? "<!-- #{text} -->" : "<p><strong>#{ERB::Util.html_escape(text)}</strong></p>"
25
+ hide ? "<!-- #{text} -->" : "<p><strong>#{CGI.escapeHTML(text.to_s)}</strong></p>"
26
26
  end
27
27
  end
28
28
  end
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'lowkey'
4
+
3
5
  require_relative 'code_blocks'
4
6
  require_relative 'directives'
5
7
  require_relative 'front_matter'
6
8
  require_relative 'nodes'
9
+ require_relative 'variables'
7
10
 
8
11
  module Raindown
9
12
  # Finds node blocks in a file's lines: a node's directive and the Markdown it wrote below it.
@@ -25,8 +28,10 @@ module Raindown
25
28
  SELF_CLOSING = /\A( {0,3})<\{\s*:(#{NAME})(?:\s+(.*?))?\s*\}>[ \t]*$/
26
29
  BARE_SELF_CLOSING = /\A( {0,3}):(#{NAME})(?:[ \t]+(.*?))?[ \t]*$/
27
30
  # A prop is name=value or name: value, where the value is quoted text, a number, true, false
28
- # or a front matter key like author.name.
31
+ # or a variable like author.name.
29
32
  PROP = /(\w+)(?:=|:[ \t]+)("[^"]*"|'[^']*'|\S+)/
33
+ # A value before the props is the node's argument.
34
+ ARG = /\A("[^"]*"|'[^']*'|[^\s"']\S*)(?:\s+|\z)/
30
35
 
31
36
  module_function
32
37
 
@@ -79,7 +84,7 @@ module Raindown
79
84
  found, pattern = directive(lines[index], bare:)
80
85
  return unless found && (klass = Nodes.node_class(found[2]))
81
86
 
82
- node = Nodes.instance(klass, props(found[3].to_s, front_matter))
87
+ node = Nodes.instance(klass, *args(found[3].to_s, Variables.new(front_matter)))
83
88
  case pattern
84
89
  when :open then end_tag(lines, index, found[2]).then { |close| { open: index, close:, last: close, node: } }
85
90
  when :bare_open then bare_block(lines, index, node)
@@ -116,17 +121,24 @@ module Raindown
116
121
  close
117
122
  end
118
123
 
119
- def props(text, front_matter)
120
- text.scan(PROP).to_h { |name, value| [name.to_sym, value(value, front_matter)] }
124
+ # The props, after a value that's the node's argument when it comes first, like 'data.csv' in chart: 'data.csv'.
125
+ def args(text, variables)
126
+ arg = text.match(ARG) unless text.match?(/\A#{PROP}/)
127
+ return [props(text, variables)] unless arg
128
+
129
+ [props(arg.post_match, variables), [value(arg[1], variables)]]
121
130
  end
122
131
 
123
- def value(value, front_matter)
124
- case value
125
- when /\A(["']).*\1\z/ then value[1..-2]
126
- when /\A-?\d+\z/ then value.to_i
127
- when /\A-?\d+\.\d+\z/ then value.to_f
128
- when 'true', 'false' then value == 'true'
129
- else value.split('.').reduce(front_matter) { |data, key| data.is_a?(Hash) ? data[key.to_sym] : nil }
132
+ def props(text, variables)
133
+ text.scan(PROP).to_h { |name, value| [name.to_sym, value(value, variables)] }
134
+ end
135
+
136
+ # A value, like 'text', 3, true, nil or :draft, or else a variable, like author.name.
137
+ def value(value, variables)
138
+ Lowkey::ValueParser.parse(value) do
139
+ next value[1..-2] if value.match?(/\A(["']).*\1\z/)
140
+
141
+ Variables.value(variables, value)
130
142
  end
131
143
  end
132
144
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'chart/chart'
3
4
  require_relative 'files'
4
5
  require_relative 'node'
5
6
  require_relative 'toc'
@@ -11,8 +12,8 @@ module Raindown
11
12
  # document: (the Markdown being rendered), metadata: and bare:, and gets the ones it asks for.
12
13
  module Nodes
13
14
  FOLDER = '.raindown'
14
- # Lowercase directives for Raindown's own nodes. A project's own TOC or Files node replaces Raindown's.
15
- BUILT_INS = { 'toc' => 'TOC', 'files' => 'Files' }.freeze
15
+ # Lowercase directives for Raindown's own nodes. A project's own TOC, Files or Chart node replaces Raindown's.
16
+ BUILT_INS = { 'toc' => 'TOC', 'files' => 'Files', 'chart' => 'Chart' }.freeze
16
17
  # A placeholder for a node's HTML, which stays as it is through Commonmarker and Antlers.
17
18
  PLACEHOLDER = /<!-- raindown:node (\d+) -->/
18
19
 
@@ -78,11 +79,11 @@ module Raindown
78
79
 
79
80
  # One instance for each node and its props, kept for the whole process.
80
81
  # So a node can keep what its Markdown and HTML share, like a date, and both stay the same between renders.
81
- def instance(klass, props)
82
- key = [klass, props]
82
+ def instance(klass, props, args = [])
83
+ key = [klass, args, props]
83
84
  instances[key] ||= begin
84
85
  id = list.length
85
- list << klass.new(**props)
86
+ list << klass.new(*args, **props)
86
87
  id
87
88
  end
88
89
 
data/lib/raindown/page.rb CHANGED
@@ -14,12 +14,13 @@ module Raindown
14
14
 
15
15
  # Rendered when asked for, so a file that lists itself doesn't render forever.
16
16
  # The front matter is rendered with it, so its variables work, and a Raindown file's bare directives too.
17
+ # It follows the file's project config, like render_file, unless the render options say otherwise.
17
18
  def content
18
19
  @content ||= begin
19
20
  markdown = File.read(file_path, encoding: Encoding::UTF_8)
20
21
 
21
22
  if FrontMatter.parse(markdown).last.strip.empty? then ''
22
- else Raindown.render(markdown:, bare: BareDirectives.file?(file_path), **@render_options)
23
+ else Raindown.render(markdown:, bare: BareDirectives.file?(file_path), **Config.render_options(file_path).merge(@render_options))
23
24
  end
24
25
  end
25
26
  end
@@ -31,6 +31,8 @@ module Raindown
31
31
  end
32
32
  end
33
33
 
34
+ attr_reader :root
35
+
34
36
  # Render options, like hide_directives: true, are for rendering each listed file's content.
35
37
  def initialize(metadata:, root:, render_options: {})
36
38
  @root = root
data/lib/raindown/toc.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'erb'
4
- require 'nokogiri'
3
+ require 'cgi/escape'
5
4
 
6
5
  require_relative 'node'
7
6
 
@@ -9,6 +8,12 @@ module Raindown
9
8
  # The node behind toc: and :toc, a table of contents of the document's headings.
10
9
  # A project can replace it with its own TOC node in .raindown, like "class TOC < Raindown::TOC".
11
10
  class TOC < Node
11
+ # A heading with an id, in the HTML that Commonmarker and Raindown's components write, like <h2 id="setup">.
12
+ HEADING = %r{<h([1-6])\b[^>]*?\bid=(?:"([^"]*)"|'([^']*)'|([^\s>]+))[^>]*>(.*?)</h\1\s*>}mi
13
+ # Comments and scripts can hold heading tags that aren't in the page.
14
+ IGNORED = %r{<!--.*?-->|<script\b.*?</script\s*>}mi
15
+ TAG = /<[^>]*>/
16
+
12
17
  def replace(document:, metadata: nil, bare: false)
13
18
  headings = headings(markdown: document, metadata:, bare:)
14
19
  return '' if headings.empty?
@@ -23,7 +28,7 @@ module Raindown
23
28
  # With the document's final HTML, the headings include ones its components render.
24
29
  def render(document:, html: nil, metadata: nil, bare: false)
25
30
  items = headings(markdown: document, html:, metadata:, bare:).map do |heading|
26
- "<li class='h#{heading[:level]}'><a href='##{heading[:id]}'>#{ERB::Util.html_escape(heading[:text])}</a></li>"
31
+ "<li class='h#{heading[:level]}'><a href='##{heading[:id]}'>#{CGI.escapeHTML(heading[:text])}</a></li>"
27
32
  end
28
33
 
29
34
  <<~HTML
@@ -42,8 +47,8 @@ module Raindown
42
47
  def headings(markdown:, html: nil, metadata: nil, bare: false)
43
48
  html ||= Fragments.collect { Raindown.assembled(markdown:, metadata: metadata || NoMetadata, bare:) }
44
49
 
45
- Nokogiri::HTML(html).css(HEADINGS).map do |heading|
46
- { level: heading.name[1].to_i, text: heading.text.strip, id: heading['id'] }
50
+ html.gsub(IGNORED, '').scan(HEADING).map do |level, *ids, content|
51
+ { level: level.to_i, text: CGI.unescapeHTML(content.gsub(TAG, '')).strip, id: CGI.unescapeHTML(ids.compact.first) }
47
52
  end
48
53
  end
49
54
  end
@@ -16,6 +16,21 @@ module Raindown
16
16
  end
17
17
  end
18
18
 
19
+ # A wrapped value as the data it was, so a variable in a node's props is a hash, list or text like any other.
20
+ def self.unwrap(value)
21
+ case value
22
+ when Variables then value.to_h
23
+ when ::Array then value.map { |item| unwrap(item) }
24
+ when ::String then value.tr(CODE_BRACES.values.join, CODE_BRACES.keys.join)
25
+ else value
26
+ end
27
+ end
28
+
29
+ # A variable's value, like author.name, read the way {author.name} reads it, unwrapped. Unknown names are nil.
30
+ def self.value(variables, name)
31
+ unwrap(name.split('.').reduce(variables) { |value, key| value.respond_to?(key) ? value.send(key) : nil })
32
+ end
33
+
19
34
  def initialize(values)
20
35
  @values = values.to_h { |key, value| [key.to_sym, Variables.wrap(value)] }
21
36
  end
@@ -32,6 +47,10 @@ module Raindown
32
47
  ::Kernel.binding
33
48
  end
34
49
 
50
+ def to_h
51
+ @values.transform_values { |value| Variables.unwrap(value) }
52
+ end
53
+
35
54
  def to_s
36
55
  @values.to_s
37
56
  end
@@ -19,8 +19,10 @@ module Raindown
19
19
 
20
20
  $stdout.sync = true
21
21
 
22
- rewrite
23
- render
22
+ safely do
23
+ rewrite
24
+ render
25
+ end
24
26
  puts "Watching #{path} for changes (writing to #{output})..."
25
27
 
26
28
  listener.start
@@ -35,14 +37,26 @@ module Raindown
35
37
 
36
38
  Listen.to(directory) do |modified, added, _removed|
37
39
  next unless (modified + added).any? { |f| File.basename(f) == filename }
38
- # Formatting saves the file again, and that change triggers the render.
39
- next if rewrite
40
40
 
41
- render
42
- puts "Re-rendered #{path} -> #{output}"
41
+ safely do
42
+ # Formatting saves the file again, and that change triggers the render.
43
+ next if rewrite
44
+
45
+ render
46
+ puts "Re-rendered #{path} -> #{output}"
47
+ end
43
48
  end
44
49
  end
45
50
 
51
+ # An error in the file is shown rather than stopping the watcher, so it can be fixed and saved again.
52
+ def safely
53
+ yield
54
+ rescue StandardError => e
55
+ raise if ENV['RAINDOWN_DEBUG']
56
+
57
+ warn "raindown: #{path}: #{e.message}"
58
+ end
59
+
46
60
  # The files' metadata is loaded again each time, so new and renamed files are listed.
47
61
  def rewrite
48
62
  Rewriter.update(path:, bare:, execute:, metadata: Pages.for(path))
data/lib/raindown.rb CHANGED
@@ -27,34 +27,51 @@ module Raindown
27
27
  MARKDOWN_OPTIONS = { extension: { alerts: true, front_matter_delimiter: '---' } }.freeze
28
28
  # Directives are HTML comments until Antlers renders them, and escaped characters like "\#" stay plain text.
29
29
  HTML_OPTIONS = { render: { unsafe: true, escaped_char_spans: false } }.freeze
30
+ # Without highlighting, code blocks are left for the browser to highlight, like <pre><code class="language-ruby">.
31
+ UNHIGHLIGHTED_OPTIONS = { render: { **HTML_OPTIONS[:render], github_pre_lang: false } }.freeze
30
32
 
31
33
  # Stands in for an app's metadata when there isn't any, so list: and files: list nothing.
32
34
  module NoMetadata
33
35
  def self.list(**) = []
34
36
  end
35
37
 
36
- # Headings with an id, which are the ones a table of contents links to.
37
- HEADINGS = 'h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]'
38
-
39
38
  module_function
40
39
 
41
- # Directives show in bold, unless the file's project hides them in .raindown/config.yml.
42
- def render_file(path:, metadata: NoMetadata, bare: false, hide_directives: Config.for(path)[:hide_directives])
43
- render(markdown: File.read(path, encoding: Encoding::UTF_8), metadata:, bare: bare || BareDirectives.file?(path), hide_directives:)
40
+ # Directives show in bold and code is highlighted, unless the file's project changes them in .raindown/config.yml.
41
+ def render_file(path:, metadata: NoMetadata, bare: false, **options)
42
+ options = Config.render_options(path).merge(options)
43
+ render(markdown: File.read(path, encoding: Encoding::UTF_8), metadata:, bare: bare || BareDirectives.file?(path), **options)
44
44
  end
45
45
 
46
46
  # Front matter keys are variables, like {title} or <{ title }>.
47
47
  # Directives like "toc:" export to HTML as bold or comments depending on hide_directives config.
48
- def render(markdown:, metadata: NoMetadata, bare: false, hide_directives: false)
49
- Fragments.collect do
50
- # Components' HTML goes back in, then their headings get unique ids, before nodes like the TOC see it.
51
- html = assembled(markdown:, metadata:, bare:)
52
- html = Nodes.render_placeholders(html, document: markdown, metadata:, bare:, html:)
53
-
54
- Directives.render("#{html.chomp}\n", hide: hide_directives)
48
+ # Code blocks are highlighted, unless highlight_code is false for a site that highlights code in the browser.
49
+ def render(markdown:, metadata: NoMetadata, bare: false, hide_directives: false, highlight_code: true)
50
+ highlighting(highlight_code) do
51
+ Fragments.collect do
52
+ # Components' HTML goes back in, then their headings get unique ids, before nodes like the TOC see it.
53
+ html = assembled(markdown:, metadata:, bare:)
54
+ html = Nodes.render_placeholders(html, document: markdown, metadata:, bare:, html:)
55
+
56
+ Directives.render("#{html.chomp}\n", hide: hide_directives)
57
+ end
55
58
  end
56
59
  end
57
60
 
61
+ # Whether code is highlighted while the block runs, like one document's render,
62
+ # so components and endpoints rendering their own Markdown follow it too.
63
+ def highlighting(highlight_code)
64
+ outer = Thread.current[:raindown_highlight_code]
65
+ Thread.current[:raindown_highlight_code] = highlight_code
66
+ yield
67
+ ensure
68
+ Thread.current[:raindown_highlight_code] = outer
69
+ end
70
+
71
+ def highlight_code?
72
+ Thread.current[:raindown_highlight_code] != false
73
+ end
74
+
58
75
  # The document's HTML with its components' HTML put back, and unique heading ids.
59
76
  def assembled(markdown:, metadata: NoMetadata, bare: false)
60
77
  HeadingIds.unique(Fragments.fill(html(markdown:, variables: variables(markdown:), metadata:, bare:)))
@@ -71,8 +88,14 @@ module Raindown
71
88
  doc = Commonmarker.parse(hide_directives(markdown:), options: MARKDOWN_OPTIONS)
72
89
  preserve_code(doc:)
73
90
 
74
- doc.to_html(options: HTML_OPTIONS).gsub('<!-- ANTLERS', '<{').gsub('ANTLERS -->', '}>')
75
- .gsub(/[\u{E000}\u{E001}]/, CODE_BRACE_ENTITIES)
91
+ html = highlight_code? ? doc.to_html(options: HTML_OPTIONS) : unhighlighted_html(doc:)
92
+ html.gsub('<!-- ANTLERS', '<{').gsub('ANTLERS -->', '}>')
93
+ .gsub(/[\u{E000}\u{E001}]/, CODE_BRACE_ENTITIES)
94
+ end
95
+
96
+ # Code blocks without highlighting, with a language class for highlighting in the browser.
97
+ def unhighlighted_html(doc:)
98
+ doc.to_html(options: UNHIGHLIGHTED_OPTIONS, plugins: { syntax_highlighter: nil })
76
99
  end
77
100
 
78
101
  def variables(markdown:)
data/lib/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raindown
4
- VERSION = '0.2.2'
4
+ VERSION = '0.4.0'
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: raindown
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - maedi
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-19 00:00:00.000000000 Z
10
+ date: 2026-10-25 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: antlers
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 1.2.0
18
+ version: 1.2.2
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 1.2.0
25
+ version: 1.2.2
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: commonmarker
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -38,7 +38,7 @@ dependencies:
38
38
  - !ruby/object:Gem::Version
39
39
  version: '0'
40
40
  - !ruby/object:Gem::Dependency
41
- name: dirload
41
+ name: csv
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - ">="
@@ -52,7 +52,7 @@ dependencies:
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0'
54
54
  - !ruby/object:Gem::Dependency
55
- name: listen
55
+ name: dirload
56
56
  requirement: !ruby/object:Gem::Requirement
57
57
  requirements:
58
58
  - - ">="
@@ -66,7 +66,7 @@ dependencies:
66
66
  - !ruby/object:Gem::Version
67
67
  version: '0'
68
68
  - !ruby/object:Gem::Dependency
69
- name: lownode
69
+ name: listen
70
70
  requirement: !ruby/object:Gem::Requirement
71
71
  requirements:
72
72
  - - ">="
@@ -80,7 +80,21 @@ dependencies:
80
80
  - !ruby/object:Gem::Version
81
81
  version: '0'
82
82
  - !ruby/object:Gem::Dependency
83
- name: nokogiri
83
+ name: lowkey
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: 0.7.0
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: 0.7.0
96
+ - !ruby/object:Gem::Dependency
97
+ name: lownode
84
98
  requirement: !ruby/object:Gem::Requirement
85
99
  requirements:
86
100
  - - ">="
@@ -154,6 +168,13 @@ files:
154
168
  - lib/nodes/list_node.rb
155
169
  - lib/raindown.rb
156
170
  - lib/raindown/bare_directives.rb
171
+ - lib/raindown/chart/bars.rb
172
+ - lib/raindown/chart/chart.rb
173
+ - lib/raindown/chart/columns.rb
174
+ - lib/raindown/chart/drawing.rb
175
+ - lib/raindown/chart/line.rb
176
+ - lib/raindown/chart/points.rb
177
+ - lib/raindown/chart/sparkline.rb
157
178
  - lib/raindown/cli.rb
158
179
  - lib/raindown/code.rb
159
180
  - lib/raindown/code_blocks.rb