raindown 0.2.1 → 0.3.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: 3c36e6807f4679d073cdc969831684565451ce3f6fe1c1d09eb89b1b9526faf7
4
- data.tar.gz: 23b34dd9c842c9ad29a159c96f74ff5053df08bc146b4589f2aef22bc0a0e19f
3
+ metadata.gz: ee322a9f4df788bddfb51b6d669a7a676574620ecd1ce30e69a5682d49b8c88e
4
+ data.tar.gz: 2bb5f698d9c5e90f0c44271e4b1529512dab858dcd07beb04061ac8690a81bff
5
5
  SHA512:
6
- metadata.gz: 9db6ef5e7a58ee401621073c809f0eaf0d7147a8d265ff68d803eefbd70f161efe8a447b6ec6d5e6e3ad9952f3a7929b4c7ac8f9fa4fd2f5af8778375ff878ba
7
- data.tar.gz: d91807885cf892c6bfc944e20067c87a317558d3062390767bfb2cb32429c88936e9bc90e76e891258bb913e2d3753310a930d140043a75f37dfef3f9a7b9b3c
6
+ metadata.gz: e550608449c87c693edef3c249472a89c62618de92941b81e135250462bbc74a062b61b2e025ea8666ff571b5057a25b67a13ed6fb385f304e500448f09a3034
7
+ data.tar.gz: a60db4e479d593ea779e93c3c683401519599ac0bf1d07f6f1e2a280634eff26b5f46167f27d5ce24fcd08f54660cb4051ed21e0c422951247a8f3d2c53938a7
@@ -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
@@ -72,7 +72,7 @@ module Raindown
72
72
  def render_stdin(path, output = nil, bare: false, **)
73
73
  Nodes.load(path)
74
74
  write(output, Raindown.render(markdown: $stdin.read, metadata: Pages.for(path), bare: bare || BareDirectives.file?(path),
75
- hide_directives: Config.for(path)[:hide_directives]))
75
+ **Config.render_options(path)))
76
76
  end
77
77
 
78
78
  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
@@ -4,6 +4,7 @@ require_relative 'code_blocks'
4
4
  require_relative 'directives'
5
5
  require_relative 'front_matter'
6
6
  require_relative 'nodes'
7
+ require_relative 'variables'
7
8
 
8
9
  module Raindown
9
10
  # Finds node blocks in a file's lines: a node's directive and the Markdown it wrote below it.
@@ -25,8 +26,10 @@ module Raindown
25
26
  SELF_CLOSING = /\A( {0,3})<\{\s*:(#{NAME})(?:\s+(.*?))?\s*\}>[ \t]*$/
26
27
  BARE_SELF_CLOSING = /\A( {0,3}):(#{NAME})(?:[ \t]+(.*?))?[ \t]*$/
27
28
  # 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.
29
+ # or a variable like author.name.
29
30
  PROP = /(\w+)(?:=|:[ \t]+)("[^"]*"|'[^']*'|\S+)/
31
+ # A value before the props is the node's argument.
32
+ ARG = /\A("[^"]*"|'[^']*'|[^\s"']\S*)(?:\s+|\z)/
30
33
 
31
34
  module_function
32
35
 
@@ -79,7 +82,7 @@ module Raindown
79
82
  found, pattern = directive(lines[index], bare:)
80
83
  return unless found && (klass = Nodes.node_class(found[2]))
81
84
 
82
- node = Nodes.instance(klass, props(found[3].to_s, front_matter))
85
+ node = Nodes.instance(klass, *args(found[3].to_s, Variables.new(front_matter)))
83
86
  case pattern
84
87
  when :open then end_tag(lines, index, found[2]).then { |close| { open: index, close:, last: close, node: } }
85
88
  when :bare_open then bare_block(lines, index, node)
@@ -116,17 +119,25 @@ module Raindown
116
119
  close
117
120
  end
118
121
 
119
- def props(text, front_matter)
120
- text.scan(PROP).to_h { |name, value| [name.to_sym, value(value, front_matter)] }
122
+ # The props, after a value that's the node's argument when it comes first, like 'data.csv' in chart: 'data.csv'.
123
+ def args(text, variables)
124
+ arg = text.match(ARG) unless text.match?(/\A#{PROP}/)
125
+ return [props(text, variables)] unless arg
126
+
127
+ [props(arg.post_match, variables), [value(arg[1], variables)]]
128
+ end
129
+
130
+ def props(text, variables)
131
+ text.scan(PROP).to_h { |name, value| [name.to_sym, value(value, variables)] }
121
132
  end
122
133
 
123
- def value(value, front_matter)
134
+ def value(value, variables)
124
135
  case value
125
136
  when /\A(["']).*\1\z/ then value[1..-2]
126
137
  when /\A-?\d+\z/ then value.to_i
127
138
  when /\A-?\d+\.\d+\z/ then value.to_f
128
139
  when 'true', 'false' then value == 'true'
129
- else value.split('.').reduce(front_matter) { |data, key| data.is_a?(Hash) ? data[key.to_sym] : nil }
140
+ else Variables.value(variables, value)
130
141
  end
131
142
  end
132
143
  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
- markdown = File.read(file_path)
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
@@ -60,7 +62,7 @@ module Raindown
60
62
  end
61
63
 
62
64
  def process(file_path:)
63
- metadata = implicit_metadata(file_path:).merge(FrontMatter.parse(File.read(file_path)).first)
65
+ metadata = implicit_metadata(file_path:).merge(FrontMatter.parse(File.read(file_path, encoding: Encoding::UTF_8)).first)
64
66
  @metadata[file_path] = metadata
65
67
  metadata.each { |type, tag| Array(tag).each { |item| tag(type:, tag: item, file_path:) } }
66
68
  end
@@ -21,7 +21,7 @@ module Raindown
21
21
 
22
22
  # Returns true when the file changed.
23
23
  def update(path:, bare: false, execute: false, metadata: NoMetadata)
24
- content = File.read(path)
24
+ content = File.read(path, encoding: Encoding::UTF_8)
25
25
  rewritten = rewrite(content:, path:, bare:, execute:, metadata:)
26
26
  return false if rewritten == content
27
27
 
@@ -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
data/lib/raindown.rb CHANGED
@@ -27,6 +27,8 @@ 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
@@ -38,23 +40,41 @@ module Raindown
38
40
 
39
41
  module_function
40
42
 
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), metadata:, bare: bare || BareDirectives.file?(path), hide_directives:)
43
+ # Directives show in bold and code is highlighted, unless the file's project changes them in .raindown/config.yml.
44
+ def render_file(path:, metadata: NoMetadata, bare: false, **options)
45
+ options = Config.render_options(path).merge(options)
46
+ render(markdown: File.read(path, encoding: Encoding::UTF_8), metadata:, bare: bare || BareDirectives.file?(path), **options)
44
47
  end
45
48
 
46
49
  # Front matter keys are variables, like {title} or <{ title }>.
47
50
  # 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)
51
+ # Code blocks are highlighted, unless highlight_code is false for a site that highlights code in the browser.
52
+ def render(markdown:, metadata: NoMetadata, bare: false, hide_directives: false, highlight_code: true)
53
+ highlighting(highlight_code) do
54
+ Fragments.collect do
55
+ # Components' HTML goes back in, then their headings get unique ids, before nodes like the TOC see it.
56
+ html = assembled(markdown:, metadata:, bare:)
57
+ html = Nodes.render_placeholders(html, document: markdown, metadata:, bare:, html:)
58
+
59
+ Directives.render("#{html.chomp}\n", hide: hide_directives)
60
+ end
55
61
  end
56
62
  end
57
63
 
64
+ # Whether code is highlighted while the block runs, like one document's render,
65
+ # so components and endpoints rendering their own Markdown follow it too.
66
+ def highlighting(highlight_code)
67
+ outer = Thread.current[:raindown_highlight_code]
68
+ Thread.current[:raindown_highlight_code] = highlight_code
69
+ yield
70
+ ensure
71
+ Thread.current[:raindown_highlight_code] = outer
72
+ end
73
+
74
+ def highlight_code?
75
+ Thread.current[:raindown_highlight_code] != false
76
+ end
77
+
58
78
  # The document's HTML with its components' HTML put back, and unique heading ids.
59
79
  def assembled(markdown:, metadata: NoMetadata, bare: false)
60
80
  HeadingIds.unique(Fragments.fill(html(markdown:, variables: variables(markdown:), metadata:, bare:)))
@@ -71,8 +91,14 @@ module Raindown
71
91
  doc = Commonmarker.parse(hide_directives(markdown:), options: MARKDOWN_OPTIONS)
72
92
  preserve_code(doc:)
73
93
 
74
- doc.to_html(options: HTML_OPTIONS).gsub('<!-- ANTLERS', '<{').gsub('ANTLERS -->', '}>')
75
- .gsub(/[\u{E000}\u{E001}]/, CODE_BRACE_ENTITIES)
94
+ html = highlight_code? ? doc.to_html(options: HTML_OPTIONS) : unhighlighted_html(doc:)
95
+ html.gsub('<!-- ANTLERS', '<{').gsub('ANTLERS -->', '}>')
96
+ .gsub(/[\u{E000}\u{E001}]/, CODE_BRACE_ENTITIES)
97
+ end
98
+
99
+ # Code blocks without highlighting, with a language class for highlighting in the browser.
100
+ def unhighlighted_html(doc:)
101
+ doc.to_html(options: UNHIGHLIGHTED_OPTIONS, plugins: { syntax_highlighter: nil })
76
102
  end
77
103
 
78
104
  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.1'
4
+ VERSION = '0.3.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.1
4
+ version: 0.3.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-20 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
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: csv
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
40
54
  - !ruby/object:Gem::Dependency
41
55
  name: dirload
42
56
  requirement: !ruby/object:Gem::Requirement
@@ -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