raindown 0.17.2 → 0.18.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: ce48803b10f9d818849d224607930c3f41c63d66a830e9da2fa6ef43c877a5cb
4
- data.tar.gz: 040a45c39fc4d1439d80af01c7c6d9d937d24850f69093c9e478c79964305bb6
3
+ metadata.gz: baf58826a9f492870795c2cfb36104ffb214b7494c0b14a2b11aa5f5a7711c64
4
+ data.tar.gz: 136d56da8dd7574698769f971bcce51662c89f29b28e2b87acb758017f9ee708
5
5
  SHA512:
6
- metadata.gz: 1e1109ca3ea21b6ae214b5d3bff967d2936e53b23ea0c71b1ca97bbcc208ac4249f5b14d151fb9a94f575d56fbdbfde12ccba1c12bf1b4a287ab632508df1260
7
- data.tar.gz: 22ba4b827f93a2c9edaf6d21c7be2e8d6916b5a95627e2f29b1309eec01eb6de59cb126ab9874a725ca3b089813529ec7b902501541c06661e5d87922ca9ee41
6
+ metadata.gz: 2ceb319ab5a5c1482a6d09f98b75e95bf22ece5ca8477b54621ee542691c6de3ee243dc0ef3f580971167cb116990a89a28ae5a4012b50c16ec659a8fcdf5cfa
7
+ data.tar.gz: 411f712343878e32a1efc7d99932254984e3f0dc45ba91a8b41316fcbf61e9aa0c404ea7d66fc75e0293975d60632e28bde04adcd044d8e3dc36c02ddf9390c2
@@ -4,6 +4,7 @@ require_relative '../raindown'
4
4
  require_relative 'bare_directives'
5
5
  require_relative 'front_matter'
6
6
  require_relative 'node_blocks'
7
+ require_relative 'written'
7
8
 
8
9
  module Raindown
9
10
  # Writes each node block's Markdown below its directive, replacing what it wrote before, when it's in comments or
@@ -11,6 +12,10 @@ module Raindown
11
12
  class NodeInjector
12
13
  class BlankLine < StandardError; end
13
14
 
15
+ # A comment on its own line that isn't a directive, like <!-- 2 rows · 3f2a9c -->, which straight below a directive
16
+ # is its node's comment, passed to its rewrite as comment:.
17
+ COMMENT = /\A\s*<!--(?!\s*(?::[A-Za-z]|[A-Za-z][\w-]*(?:::[A-Z]\w*)*:))\s*(.*?)\s*-->\s*\z/
18
+
14
19
  # Rewriter interface. Content without a node block is returned unchanged.
15
20
  def self.rewrite(content:, path: nil, bare: false, metadata: NoMetadata, **)
16
21
  new(content:, path:, bare:, metadata:).call
@@ -42,22 +47,25 @@ module Raindown
42
47
  private
43
48
 
44
49
  def block_lines(lines, block)
45
- context = { document: @content, metadata: @metadata, bare: @bare, written: written_lines(lines, block) }
46
- markdown = node_markdown(block, context)
47
- written = markdown.empty? ? [] : "#{markdown}\n".lines
50
+ comment, written = comment_and_written(written_lines(lines, block))
51
+ context = { document: @content, metadata: @metadata, bare: @bare, comment:, written: }
52
+ written = written_lines_of(node_written(block, context))
48
53
  return [line(lines[block[:open]]), *bare(written, block)] unless block[:close]
49
54
 
50
55
  delimited(lines, block, written)
51
56
  end
52
57
 
53
58
  # A node that raises writes why in its place, like a Problem, so the rest of the file still writes back.
54
- def node_markdown(block, context)
59
+ def node_written(block, context)
55
60
  @problems << block[:node].warning if block[:node].is_a?(Problem)
56
- Nodes.call(block[:node], :rewrite, **context).to_s.chomp
61
+ written = Nodes.call(block[:node], :rewrite, **context)
62
+ return written if written.is_a?(Written)
63
+
64
+ Written.new(written.to_s)
57
65
  rescue StandardError => e
58
66
  problem = Problem.new("Couldn't write: #{e.message}", block[:open] + 1)
59
67
  @problems << problem.warning
60
- problem.rewrite
68
+ Written.new(problem.rewrite)
61
69
  end
62
70
 
63
71
  # The directive, what the node wrote and the end tag, straight after each other, as a comment on its own line ends
@@ -72,6 +80,22 @@ module Raindown
72
80
  raise BlankLine, "#{name} writes blank lines, so it needs <!-- #{name}: --> and <!-- :#{name} --> around it"
73
81
  end
74
82
 
83
+ # Its lines: its comment, straight below its directive, then its Markdown.
84
+ def written_lines_of(written)
85
+ markdown = written.to_s.chomp
86
+ lines = markdown.empty? ? [] : "#{markdown}\n".lines
87
+ return lines unless written.comment
88
+
89
+ ["<!-- #{written.comment} -->\n", *lines]
90
+ end
91
+
92
+ # The comment the block's node wrote straight below its directive, if it wrote one, and the lines it wrote below that.
93
+ def comment_and_written(lines)
94
+ return [nil, lines] unless lines.first&.match?(COMMENT)
95
+
96
+ [lines.first[COMMENT, 1], lines.drop(1)]
97
+ end
98
+
75
99
  # What the block's node wrote last time, between its directive and its end tag, or the next blank line.
76
100
  def written_lines(lines, block)
77
101
  lines[(block[:open] + 1)...(block[:close] || (block[:last] + 1))]
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'table_formulas'
4
+
5
+ module Raindown
6
+ # A cell of a table: block's file, as its table shows it: its value, worked out from its formula, and clipped to fit
7
+ # its column, ending in an ellipsis, when the column's fixed. Deleting the padding around a formula's value shows its
8
+ # formula, and deleting a clipped cell's ellipsis shows its full text, wrapped onto |+ rows, each until it's changed,
9
+ # or, for its full text, an ellipsis is typed into it again, which clips it.
10
+ FileCell = Data.define(:saved, :value, :column) do
11
+ # As the table shows it, unless it's revealed.
12
+ def shown
13
+ return value unless column
14
+
15
+ column.clip(value)
16
+ end
17
+
18
+ # Whether the written cell still shows it: its value, as shown or in full, or clipped at another width, as it was
19
+ # before its column's width changed, or in full with an ellipsis typed into it, its formula, ignoring whitespace, or
20
+ # its clipped start, with the ellipsis deleted.
21
+ def unchanged_by?(cell)
22
+ [shown, value, revealing].include?(cell) || formula_in?(cell) || clip_of_value?(cell) || collapsing?(cell)
23
+ end
24
+
25
+ # Whether the written cell shows its formula, or its full text, in place of what the table shows. A full text written
26
+ # on one line, before its column was fixed, isn't, so it's clipped.
27
+ def revealed_by?(cell, squeezed:, wrapped:)
28
+ return formula_in?(cell) || (cell == value && squeezed) if formula?
29
+
30
+ clipped? && (cell == revealing || (cell == value && wrapped))
31
+ end
32
+
33
+ # The value the written cell gives it: its own while it's unchanged, or else what was typed, with the end that was
34
+ # clipped off back in place of the ellipsis, while it has one, like when its start's edited, or more typed after it.
35
+ def value_from(cell)
36
+ return value if unchanged_by?(cell)
37
+ return cell unless cell.include?('…')
38
+
39
+ before, _, after = cell.partition('…')
40
+ hidden = clipped_off(before)
41
+ return cell unless hidden
42
+
43
+ "#{before}#{hidden}#{after}"
44
+ end
45
+
46
+ private
47
+
48
+ def formula?
49
+ TableFormulas.formula?(saved)
50
+ end
51
+
52
+ def formula_in?(cell)
53
+ formula? && cell.gsub(/\s+/, '') == saved.gsub(/\s+/, '')
54
+ end
55
+
56
+ # Its full text with an ellipsis typed into it, like … or ..., anywhere between its words, to clip it again.
57
+ def collapsing?(cell)
58
+ ellipsis = /\s*(?:…|\.\.\.)\s*/
59
+ cell.match?(ellipsis) && cell.sub(ellipsis, ' ').strip == value
60
+ end
61
+
62
+ def clip_of_value?(cell)
63
+ cell.end_with?('…') && value.length >= cell.length && value.start_with?(cell.delete_suffix('…'))
64
+ end
65
+
66
+ # The end clipped off after the start before the ellipsis: the rest of its value when that's how it starts, or else,
67
+ # when its start's been edited, what's after the start it's clipped to now.
68
+ def clipped_off(before)
69
+ return value.delete_prefix(before) if value.start_with?(before)
70
+
71
+ value.delete_prefix(revealing) if clipped?
72
+ end
73
+
74
+ def clipped?
75
+ shown != value
76
+ end
77
+
78
+ # Its clipped start, without the ellipsis, which shows its full text when that's deleted.
79
+ def revealing
80
+ shown.delete_suffix('…') if clipped?
81
+ end
82
+ end
83
+ end
@@ -2,19 +2,36 @@
2
2
 
3
3
  module Raindown
4
4
  # Rows of cells as a Markdown table, its first row the header, and back again. A | in a cell is escaped as \|, and
5
- # a line break is written as a space, as a table's cells are one line. The TableFormatter lines up its columns.
5
+ # a line break is written as a space, as a table's cells are one line. A row starting with |+, carrying on the row
6
+ # above, is read as part of it. The TableFormatter lines up its columns.
6
7
  module MarkdownTable
7
- SEPARATOR = /\A\|?(\s*:?-+:?\s*\|)*\s*:?-+:?\s*\|?\z/
8
+ SEPARATOR = /\A\|?(\s*:?[-=]+:?\s*\|)*\s*:?[-=]+:?\s*\|?\z/
8
9
  # A | that isn't escaped.
9
10
  PIPE = /(?<!\\)\|/
10
11
 
11
12
  module_function
12
13
 
13
- def format(rows)
14
+ # With the delimiter's cells, like ':===', when it has one for each column, keeping their alignment and widths. A
15
+ # cell can be lines, wrapped onto |+ rows below its row.
16
+ def format(rows, delimiter = nil)
14
17
  return '' if rows.empty?
15
18
 
16
- header, *body = rows.map { |row| "| #{row.map { escape(it) }.join(' | ')} |" }
17
- [header, "|#{Array.new(rows.first.length, '---').join('|')}|", *body].join("\n")
19
+ delimiter = Array.new(rows.first.length, '---') unless delimiter&.length == rows.first.length
20
+ header, *body = rows.flat_map { row_lines(it) }
21
+ [header, "|#{delimiter.join('|')}|", *body].join("\n")
22
+ end
23
+
24
+ def row_lines(row)
25
+ lines = row.map { Array(it) }
26
+ starts = ['| ', *Array.new(lines.map(&:length).max - 1, '|+')]
27
+ starts.each_with_index.map { |start, line| "#{start}#{lines.map { escape(it[line].to_s) }.join(' | ')} |" }
28
+ end
29
+
30
+ # The cells of the table's delimiter row, like ['---', ':==='], or nil when the lines aren't a table.
31
+ def delimiter(lines)
32
+ return unless lines[1]&.strip&.match?(SEPARATOR)
33
+
34
+ lines[1].strip.delete_prefix('|').delete_suffix('|').split('|').map(&:strip)
18
35
  end
19
36
 
20
37
  # The rows of the table in the lines, leaving out its separator, or none when they aren't a table.
@@ -22,16 +39,40 @@ module Raindown
22
39
  padded_rows(lines).map { |row| row.map { cell(it) } }
23
40
  end
24
41
 
25
- # The rows of the table in the lines, their cells as written, padding and all, leaving out its separator.
42
+ # The rows of the table in the lines, their cells as written, padding and all, leaving out its separator, with each
43
+ # |+ row's cells joined into the row above's, on lines of their own.
26
44
  def padded_rows(lines)
27
- return [] unless lines[1]&.strip&.match?(SEPARATOR)
45
+ return [] unless delimiter(lines)
46
+
47
+ table_lines = [lines[0], *lines.drop(2)].map(&:strip).take_while { it.start_with?('|') }
48
+ table_lines.each_with_object([]) do |line, rows|
49
+ cells = padded_cells(line)
50
+ next rows << cells unless line.start_with?('|+') && rows.any?
51
+
52
+ rows[-1] = rows.last.zip(cells).map { |above, more| joined_cell(above, more.to_s) }
53
+ end
54
+ end
55
+
56
+ # The line's cells, padding and all, without its outer pipes, or the |+ starting it.
57
+ def padded_cells(line)
58
+ line.delete_prefix(lead(line)).delete_suffix('|').split(PIPE, -1)
59
+ end
60
+
61
+ def lead(line)
62
+ return '|+' if line.start_with?('|+')
63
+
64
+ '|'
65
+ end
66
+
67
+ # The cell carried on by the one below it, or as it was, padding and all, when that's blank.
68
+ def joined_cell(above, more)
69
+ return above if more.strip.empty?
28
70
 
29
- table_lines = [lines[0], *lines.drop(2)].take_while { it.strip.start_with?('|') }
30
- table_lines.map { it.strip.delete_prefix('|').delete_suffix('|').split(PIPE, -1) }
71
+ [above.strip, more.strip].reject(&:empty?).join("\n")
31
72
  end
32
73
 
33
74
  def cell(padded)
34
- padded.strip.gsub('\|', '|')
75
+ one_line(padded.strip).gsub('\|', '|')
35
76
  end
36
77
 
37
78
  def escape(cell)
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ # Which of a file's rows each of a table's rows shows, by index, given whether a table row shows a file row, so a row
5
+ # added or removed doesn't move the rows after it onto others. Rows that are the same in both are matched, in order,
6
+ # then those left between them are paired in order, like a row that's been edited.
7
+ class MatchedRows
8
+ def initialize(rows, file_rows, &same)
9
+ @rows = rows
10
+ @file_rows = file_rows
11
+ @same = same
12
+ end
13
+
14
+ # Each table row's file row, or nil for a row that's new.
15
+ def file_rows
16
+ [].tap do |matched|
17
+ [[-1, -1], *common_rows, [@rows, @file_rows]].each_cons(2) do |(row, file_row), (next_row, next_file_row)|
18
+ matched[row] = file_row unless row.negative?
19
+ (row + 1...next_row).zip(file_row + 1...next_file_row) { |gap_row, gap_file_row| matched[gap_row] = gap_file_row }
20
+ end
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ # The longest run of rows, in order, the same in both, as pairs of their indexes. Rows the same at the start and
27
+ # end are matched first, so only the few between them, being edited, are compared each with each.
28
+ def common_rows
29
+ head = matching_run((0...@rows).zip(0...@file_rows))
30
+ rows, file_rows = [@rows, @file_rows].map { (head.length...it).to_a }
31
+ tail = matching_run(rows.reverse.zip(file_rows.reverse)).reverse
32
+ rows, file_rows = [rows, file_rows].map { it.take(it.length - tail.length) }
33
+ [*head, *common_pairs(rows, file_rows, common_lengths(rows, file_rows)), *tail]
34
+ end
35
+
36
+ def matching_run(pairs)
37
+ pairs.take_while { |row, file_row| file_row && @same.call(row, file_row) }
38
+ end
39
+
40
+ # How many rows, at most, the rows and file rows from each on have in common.
41
+ def common_lengths(rows, file_rows)
42
+ rows.reverse.each_with_object([Array.new(file_rows.length + 1, 0)]) do |row, lengths|
43
+ lengths.unshift(common_lengths_from(row, file_rows, lengths.first))
44
+ end
45
+ end
46
+
47
+ # The common lengths from the row, from those from the row below it.
48
+ def common_lengths_from(row, file_rows, below)
49
+ file_rows.each_index.reverse_each.with_object(Array.new(file_rows.length + 1, 0)) do |j, lengths|
50
+ lengths[j] = @same.call(row, file_rows[j]) ? below[j + 1] + 1 : [below[j], lengths[j + 1]].max
51
+ end
52
+ end
53
+
54
+ def common_pairs(rows, file_rows, lengths)
55
+ i = j = 0
56
+ pairs = []
57
+ while i < rows.length && j < file_rows.length
58
+ if @same.call(rows[i], file_rows[j])
59
+ pairs << [rows[i], file_rows[j]]
60
+ i += 1
61
+ j += 1
62
+ elsif lengths[i + 1][j] >= lengths[i][j + 1]
63
+ i += 1
64
+ else
65
+ j += 1
66
+ end
67
+ end
68
+ pairs
69
+ end
70
+ end
71
+ end
@@ -5,7 +5,9 @@ require 'json'
5
5
 
6
6
  require_relative '../node'
7
7
  require_relative '../utils'
8
+ require_relative '../written'
8
9
  require_relative 'markdown_table'
10
+ require_relative 'table_column'
9
11
  require_relative 'table_file'
10
12
  require_relative 'table_formulas'
11
13
  require_relative 'written_table'
@@ -14,7 +16,7 @@ module Raindown
14
16
  # The node behind table:, a CSV or TSV file relative to the project as a Markdown table, after a comment of how many
15
17
  # rows it has and a hash of them, as they were when the table and the file last matched:
16
18
  # table: 'data/team.csv'
17
- # <!-- table 2 rows · 3f2a9c -->
19
+ # <!-- 2 rows · 3f2a9c -->
18
20
  # | name | role |
19
21
  # |------|----------|
20
22
  # | Ada | engineer |
@@ -25,7 +27,8 @@ module Raindown
25
27
  # cells in the file, like =SUM(B), show their values, and keep their formulas while their values are left alone. Delete
26
28
  # the padding around one's value and it shows its formula, to edit, until it's changed.
27
29
  class Table < Node
28
- COMMENT = /\A<!-- table (?:conflict|\d+ rows?) · (\h+) -->\z/
30
+ # The hash in its comment, like 3f2a9c in "2 rows · 3f2a9c".
31
+ HASH = /· (\h+)\z/
29
32
  BOLD = /\A\*\*.*\*\*\z/
30
33
  # The table a new file starts with, when there's no table to make it from.
31
34
  STARTER = [['Column 1', 'Column 2'], ['', '']].freeze
@@ -37,11 +40,11 @@ module Raindown
37
40
 
38
41
  # Written is what it wrote last time, so it can tell which of the table and the file changed since. A file that's
39
42
  # not there yet is made, from the table below it, or else one to fill in.
40
- def rewrite(written: [], metadata: NoMetadata)
43
+ def rewrite(written: [], metadata: NoMetadata, comment: nil)
41
44
  path = data_path(metadata)
42
45
  lines = written.map(&:chomp)
43
46
  create_file(path, lines) if missing_file?(path, metadata)
44
- data_error(path, metadata)&.then { Problem.alert(it) } || synced(path, lines)
47
+ data_error(path, metadata)&.then { Problem.alert(it) } || synced(path, lines, comment)
45
48
  end
46
49
 
47
50
  # Write-in, in <{ }>, the table only shows its file, and never writes to it.
@@ -69,10 +72,9 @@ module Raindown
69
72
  TableFile.supported?(path) && Utils.inside?(path, data_root(metadata)) && !File.exist?(path)
70
73
  end
71
74
 
72
- # From the table as it's written, which has no comment yet when it's just been typed, below any alert saying why
73
- # there was no file.
75
+ # From the table as it's written, below any alert saying why there was no file.
74
76
  def create_file(path, lines)
75
- rows = MarkdownTable.parse(lines.grep_v(COMMENT).grep_v(/\A>/))
77
+ rows = MarkdownTable.parse(lines.grep_v(/\A>/))
76
78
  rows = STARTER if rows.empty?
77
79
  TableFile.create(path, rows)
78
80
  end
@@ -86,16 +88,16 @@ module Raindown
86
88
  "Can't show #{@data.inspect} as a table: no such file" unless File.file?(path)
87
89
  end
88
90
 
89
- def synced(path, written)
91
+ def synced(path, written, comment)
90
92
  file = TableFile.read(path)
91
93
  shown = TableFormulas.evaluate(file)
92
- synced_hash = written.first&.[](COMMENT, 1)
93
- table = WrittenTable.new(written_rows(written), file)
94
- return markdown(file, table.revealed) if unchanged?(table.rows, synced_hash, shown)
95
- return markdown(TableFile.write(path, with_file_cells(table.rows, file, shown)), table.revealed) if hash_rows(shown) == synced_hash
94
+ synced_hash = comment&.[](HASH, 1)
95
+ table = WrittenTable.new(written_rows(written), file, MarkdownTable.delimiter(written))
96
+ return markdown(file, table) if unchanged?(table.rows, synced_hash, shown)
97
+ return markdown(TableFile.write(path, table.saved_rows), table) if hash_rows(shown) == synced_hash
96
98
 
97
99
  warn "raindown: #{@data} and its table both changed, so neither is written until they match"
98
- ["<!-- table conflict · #{synced_hash} -->", *written.drop(1)].join("\n")
100
+ Written.new(written.join("\n"), comment: "conflict · #{synced_hash}")
99
101
  end
100
102
 
101
103
  # Whether there's nothing to write back: the table's rows are as it wrote them, or as the file's are, or it's new.
@@ -105,37 +107,27 @@ module Raindown
105
107
 
106
108
  # The table's rows as it was written, padding and all, leaving out its total.
107
109
  def written_rows(written)
108
- header, *body = MarkdownTable.padded_rows(written.drop(1))
110
+ header, *body = MarkdownTable.padded_rows(written)
109
111
  [header, *body.reject { total_row?(it.map(&:strip)) }].compact
110
112
  end
111
113
 
112
- def markdown(rows, revealed = [])
114
+ # Written with the delimiter it was written with, its cells fit to its fixed columns.
115
+ def markdown(rows, written)
113
116
  return '' if rows.empty?
114
117
 
115
118
  shown = TableFormulas.evaluate(rows)
116
119
  count = rows.length - 1
117
- table = MarkdownTable.format(with_total(with_revealed(shown, rows, revealed)))
118
- "<!-- table #{count} row#{'s' unless count == 1} · #{hash_rows(shown)} -->\n#{table}"
120
+ cells = with_total(with_revealed(shown, rows, written.revealed))
121
+ columns = TableColumn.all(written.delimiter, rows)
122
+ table = MarkdownTable.format(TableColumn.fit(cells, columns, written.revealed), written.delimiter)
123
+ Written.new(table, comment: "#{count} row#{'s' unless count == 1} · #{hash_rows(shown)}")
119
124
  end
120
125
 
121
- # The values, but for each revealed cell, which shows its formula.
126
+ # The values, but for each revealed cell, which shows its formula, or its full text.
122
127
  def with_revealed(values, rows, revealed)
123
128
  revealed.each_with_object(values.map(&:dup)) { |(row, column), shown| shown[row][column] = rows[row][column] }
124
129
  end
125
130
 
126
- # The table's rows, keeping the file's cell where the table still shows it, like a formula as its value, or a cell
127
- # with line breaks on one line.
128
- def with_file_cells(table, file, shown)
129
- table.each_with_index.map do |row, row_index|
130
- row.each_with_index.map do |cell, column|
131
- kept = file.dig(row_index, column)
132
- next kept if kept && MarkdownTable.one_line(shown.dig(row_index, column).to_s) == cell
133
-
134
- cell
135
- end
136
- end
137
- end
138
-
139
131
  def with_total(rows)
140
132
  return rows unless @total && rows.length > 1
141
133
 
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'unicode/display_width'
4
+
5
+ module Raindown
6
+ # A table's column: its alignment, from its delimiter cell's colons, and its width, that of its widest cell, or as wide
7
+ # as its delimiter when that's equals signs, like |====|, which is fixed. An equals sign typed into dashes, like
8
+ # |-=---|, makes it fixed as wide as it was, and a dash typed into equals signs, like |==-==|, makes it widen again.
9
+ TableColumn = Data.define(:delimiter, :width) do
10
+ # The columns of the delimiter's cells, while it has one for each of the rows' columns.
11
+ def self.all(delimiter, rows)
12
+ return [] unless delimiter&.length == rows.first&.length
13
+
14
+ delimiter.each_with_index.map { |cell, i| from(cell, rows.map { it[i].to_s }) }
15
+ end
16
+
17
+ # The rows with each cell clipped to fit its fixed column, but for those revealed, which are wrapped to fit it as
18
+ # lines instead, but for the header's, as + rows can only be below the delimiter.
19
+ def self.fit(rows, columns, revealed)
20
+ rows.each_with_index.map do |row, row_index|
21
+ row.each_with_index.map do |cell, column|
22
+ next cell unless columns[column]&.fixed?
23
+ next columns[column].clip(cell) unless revealed.include?([row_index, column])
24
+ next cell if row_index.zero?
25
+
26
+ columns[column].wrap(cell)
27
+ end
28
+ end
29
+ end
30
+
31
+ def self.from(delimiter, cells)
32
+ return new(delimiter:, width: delimiter.length - fixing(delimiter)) if fixed?(delimiter)
33
+
34
+ new(delimiter:, width: [*cells.map { display_width(it) }, 3].max)
35
+ end
36
+
37
+ # Wide characters like CJK and emoji take two columns. Emoji are always measured as two,
38
+ # instead of guessing from the terminal, so a file formats the same on every machine.
39
+ def self.display_width(cell)
40
+ Unicode::DisplayWidth.of(cell, emoji: :all)
41
+ end
42
+
43
+ # Equals signs, or one typed into dashes, as fewer of them than dashes.
44
+ def self.fixed?(delimiter)
45
+ equals = delimiter.count('=')
46
+ equals.positive? && (delimiter.count('-').zero? || equals < delimiter.count('-'))
47
+ end
48
+
49
+ # The equals signs typed into dashes, which aren't part of its width, or none when it's all equals signs.
50
+ def self.fixing(delimiter)
51
+ return 0 if delimiter.count('-').zero?
52
+
53
+ delimiter.count('=')
54
+ end
55
+
56
+ def fixed?
57
+ TableColumn.fixed?(delimiter)
58
+ end
59
+
60
+ def alignment
61
+ if delimiter.start_with?(':') && delimiter.end_with?(':') then :center
62
+ elsif delimiter.end_with?(':') then :right
63
+ elsif delimiter.start_with?(':') then :left
64
+ end
65
+ end
66
+
67
+ def delimiter_cell
68
+ case alignment
69
+ when :center then ":#{dashes * (width - 2)}:"
70
+ when :right then "#{dashes * (width - 1)}:"
71
+ when :left then ":#{dashes * (width - 1)}"
72
+ else dashes * width
73
+ end
74
+ end
75
+
76
+ def pad(cell)
77
+ padding = [width - TableColumn.display_width(cell), 0].max
78
+
79
+ case alignment
80
+ when :center then "#{' ' * (padding / 2)}#{cell}#{' ' * (padding - (padding / 2))}"
81
+ when :right then "#{' ' * padding}#{cell}"
82
+ else "#{cell}#{' ' * padding}"
83
+ end
84
+ end
85
+
86
+ # The cell cut to fit a fixed column, ending in an ellipsis, or as it is when it fits.
87
+ def clip(cell)
88
+ return cell unless fixed? && TableColumn.display_width(cell) > width
89
+
90
+ kept = cell.grapheme_clusters.each_with_object(+'') do |grapheme, text|
91
+ break text if TableColumn.display_width(text + grapheme) > width - 1
92
+
93
+ text << grapheme
94
+ end
95
+ "#{kept.rstrip}…"
96
+ end
97
+
98
+ # The cell's words as lines that fit the column, each as many as fit, and a word longer than it on a line of its own.
99
+ def wrap(cell)
100
+ cell.split(' ').each_with_object([]) do |word, lines|
101
+ next lines.last << " #{word}" if lines.any? && TableColumn.display_width("#{lines.last} #{word}") <= width
102
+
103
+ lines << +word
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ def dashes
110
+ return '=' if fixed?
111
+
112
+ '-'
113
+ end
114
+ end
115
+ end
@@ -1,22 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'unicode/display_width'
3
+ require_relative 'table_column'
4
4
 
5
5
  module Raindown
6
- # Pads every column of each Markdown table to the width of its widest cell.
6
+ # Pads every column of each Markdown table to the width of its widest cell, but for a column whose delimiter is
7
+ # equals signs, like |====|, which is as wide as they are, letting a longer cell run past it. A row starting with |+,
8
+ # like "|+more | |", carries on the row above it, which Raindown joins into its cells when it renders. Its |+ takes
9
+ # the place of a row's "| ", so its columns line up.
7
10
  class TableFormatter
8
- DELIMITER_CELL = /\A:?-+:?\z/
11
+ DELIMITER_CELL = /\A:?[-=]+:?\z/
9
12
  FENCE = /\A\s*(```|~~~)/
13
+ CONTINUATION = /\A\s*\|\+/
10
14
 
11
15
  def self.rewrite(content:, **)
12
16
  new(content:).format
13
17
  end
14
18
 
19
+ # The Markdown as Commonmarker reads it: each table's equals signs as dashes, and its + rows joined into the row
20
+ # above them, each cell carrying on with a space.
21
+ def self.gfm(markdown)
22
+ new(content: markdown).gfm
23
+ end
24
+
15
25
  def initialize(content:)
16
26
  @lines = content.lines
17
27
  end
18
28
 
19
29
  def format
30
+ replace_tables { align(it) }
31
+ end
32
+
33
+ def gfm
34
+ replace_tables { gfm_table(it) }
35
+ end
36
+
37
+ private
38
+
39
+ # The content with each table, outside code, replaced by the block's lines for it.
40
+ def replace_tables
20
41
  output = []
21
42
  index = 0
22
43
  in_fence = false
@@ -27,7 +48,7 @@ module Raindown
27
48
 
28
49
  if !in_fence && table_start?(index)
29
50
  table_end = find_table_end(index)
30
- output.concat(align(@lines[index...table_end]))
51
+ output.concat(yield(@lines[index...table_end]))
31
52
  index = table_end
32
53
  else
33
54
  output << line
@@ -38,12 +59,26 @@ module Raindown
38
59
  output.join
39
60
  end
40
61
 
41
- private
62
+ # Split on pipes that aren't escaped, dropping the optional outer pipes, or the |+ starting a continuation row.
63
+ def cells(line)
64
+ content = line.strip
65
+ content = content.delete_prefix(row_lead(line).rstrip)
66
+ content = content.delete_suffix('|') unless content.end_with?('\|')
67
+
68
+ content.split(/(?<!\\)\|/, -1).map(&:strip)
69
+ end
70
+
71
+ # How the row's line starts, before its first cell: |+ when it carries on the row above, which is as wide as "| ".
72
+ def row_lead(line)
73
+ return '|+' if line.match?(CONTINUATION)
74
+
75
+ '| '
76
+ end
42
77
 
43
78
  def table_start?(index)
44
79
  header = @lines[index]
45
80
  delimiter = @lines[index + 1]
46
- return false unless header&.include?('|') && delimiter
81
+ return false unless header&.include?('|') && delimiter && !header.match?(CONTINUATION)
47
82
 
48
83
  delimiter_cells = cells(delimiter)
49
84
  delimiter_cells.any? && delimiter_cells.all? { |cell| cell.match?(DELIMITER_CELL) } &&
@@ -56,79 +91,40 @@ module Raindown
56
91
  table_end
57
92
  end
58
93
 
59
- def align(table)
60
- indent = table.first[/\A\s*/]
61
- rows = table.map { |line| cells(line) }
62
- header, delimiter, *body = rows
63
- return table if body.any? { |row| row.length > header.length }
64
-
65
- table_lines(indent:, header:, body:, alignments: delimiter.map { |cell| alignment(cell) })
94
+ def gfm_table(table)
95
+ header, delimiter, *body = table.map { |line| cells(line) }
96
+ rows = joined_rows(table.drop(2), header, body)
97
+ rows.insert(1, delimiter.map { it.tr('=', '-') }).map { "#{table.first[/\A\s*/]}| #{it.join(' | ')} |\n" }
66
98
  end
67
99
 
68
- def table_lines(indent:, header:, body:, alignments:)
69
- body = body.map { |row| row + ([''] * (header.length - row.length)) }
70
- widths = column_widths(rows: [header, *body])
71
-
72
- [
73
- row_line(indent:, cells: header, widths:, alignments:),
74
- delimiter_line(indent:, widths:, alignments:),
75
- *body.map { |row| row_line(indent:, cells: row, widths:, alignments:) }
76
- ].map { |line| "#{line}\n" }
77
- end
78
-
79
- # Split on pipes that aren't escaped, dropping the optional outer pipes.
80
- def cells(line)
81
- content = line.strip
82
- content = content.delete_prefix('|')
83
- content = content.delete_suffix('|') unless content.end_with?('\|')
84
-
85
- content.split(/(?<!\\)\|/, -1).map(&:strip)
86
- end
100
+ # The rows, each with the + rows below it joined into its cells.
101
+ def joined_rows(lines, header, body)
102
+ lines.zip(body).each_with_object([header]) do |(line, row), joined|
103
+ next joined << row unless line.match?(CONTINUATION)
87
104
 
88
- def alignment(cell)
89
- if cell.start_with?(':') && cell.end_with?(':') then :center
90
- elsif cell.end_with?(':') then :right
91
- elsif cell.start_with?(':') then :left
105
+ joined[-1] = joined.last.zip(row).map { |above, more| [above, more].reject { it.to_s.empty? }.join(' ') }
92
106
  end
93
107
  end
94
108
 
95
- # Three is the shortest delimiter cell, which also fits ':-:'.
96
- def column_widths(rows:)
97
- rows.transpose.map { |column| [column.map { |cell| display_width(cell) }.max, 3].max }
98
- end
99
-
100
- # Wide characters like CJK and emoji take two columns. Emoji are always measured as two,
101
- # instead of guessing from the terminal, so a file formats the same on every machine.
102
- def display_width(cell)
103
- Unicode::DisplayWidth.of(cell, emoji: :all)
104
- end
109
+ def align(table)
110
+ header, delimiter, *body = table.map { |line| cells(line) }
111
+ return table if body.any? { |row| row.length > header.length }
105
112
 
106
- def row_line(indent:, cells:, widths:, alignments:)
107
- padded = cells.each_with_index.map { |cell, i| pad(cell, widths[i], alignments[i]) }
108
- "#{indent}| #{padded.join(' | ')} |"
113
+ rows = [header, *body.map { |row| row + ([''] * (header.length - row.length)) }]
114
+ aligned_lines(table, rows, TableColumn.all(delimiter, rows))
109
115
  end
110
116
 
111
- def delimiter_line(indent:, widths:, alignments:)
112
- dashes = widths.each_with_index.map do |width, i|
113
- case alignments[i]
114
- when :center then ":#{'-' * (width - 2)}:"
115
- when :right then "#{'-' * (width - 1)}:"
116
- when :left then ":#{'-' * (width - 1)}"
117
- else '-' * width
118
- end
117
+ def aligned_lines(table, rows, columns)
118
+ indent = table.first[/\A\s*/]
119
+ lines = rows.zip(table.values_at(0, 2...table.length)).map do |row, line|
120
+ row_line(indent, line, columns.zip(row).map { |column, cell| column.pad(cell) })
119
121
  end
120
-
121
- "#{indent}| #{dashes.join(' | ')} |"
122
+ lines.insert(1, row_line(indent, table[1], columns.map(&:delimiter_cell)))
122
123
  end
123
124
 
124
- def pad(cell, width, alignment)
125
- padding = width - display_width(cell)
126
-
127
- case alignment
128
- when :center then "#{' ' * (padding / 2)}#{cell}#{' ' * (padding - (padding / 2))}"
129
- when :right then "#{' ' * padding}#{cell}"
130
- else "#{cell}#{' ' * padding}"
131
- end
125
+ # The row's line, starting with |+ when it carries on the row above, as the line it was did.
126
+ def row_line(indent, line, cells)
127
+ "#{indent}#{row_lead(line)}#{cells.join(' | ')} |\n"
132
128
  end
133
129
  end
134
130
  end
@@ -2,32 +2,47 @@
2
2
 
3
3
  require 'unicode/display_width'
4
4
 
5
+ require_relative 'file_cell'
5
6
  require_relative 'markdown_table'
6
- require_relative 'table_formulas'
7
+ require_relative 'matched_rows'
8
+ require_relative 'table_column'
7
9
 
8
10
  module Raindown
9
- # A table: block's table as it was written, its cells padded as they were, against its file's rows. A formula cell
10
- # shows its formula, so it can be edited, when its value's padding was deleted, no longer the width of its column's
11
- # header, and keeps showing it, until it's changed from the file's, ignoring whitespace.
11
+ # A table: block's table as it was written, its cells padded as they were, against its file's cells, as its columns
12
+ # show them. Each of its rows is matched to the file's row it shows, so a row added or removed above a formula
13
+ # doesn't move it onto another. A formula's value is no longer lined up when its padding's deleted, the width of its
14
+ # column's header.
12
15
  class WrittenTable
13
- def initialize(padded_rows, file)
16
+ # The delimiter's cells, like ':===', as it was written, or nil when it's not a table yet.
17
+ attr_reader :delimiter
18
+
19
+ def initialize(padded_rows, file, delimiter = nil)
14
20
  @padded_rows = padded_rows
15
21
  @file = file
16
22
  @values = TableFormulas.evaluate(file)
23
+ @delimiter = delimiter
24
+ @columns = TableColumn.all(delimiter, file)
17
25
  end
18
26
 
19
27
  # Its rows, as wide as each other, each formula still shown as it's saved being its value, as they're compared with the file's.
20
28
  def rows
21
- cells.each_with_index.map do |row, row_index|
22
- row.each_with_index.map do |cell, column|
23
- next @values.dig(row_index, column) if same_formula?(cell, @file.dig(row_index, column))
29
+ each_cell { |cell, row, column| file_cell(row, column)&.value_from(cell) || cell }
30
+ end
31
+
32
+ # Its rows to write to the file, keeping the file's cell where the table still shows it, like a formula as its
33
+ # value, or a cell with line breaks on one line.
34
+ def saved_rows
35
+ rows.each_with_index.map do |cells, row|
36
+ cells.each_with_index.map do |cell, column|
37
+ kept = file_cell(row, column)
38
+ next kept.saved if kept && MarkdownTable.one_line(kept.value) == cell
24
39
 
25
40
  cell
26
41
  end
27
42
  end
28
43
  end
29
44
 
30
- # The row and column of each cell to show as its formula.
45
+ # The row and column of each cell to show as its formula, or in full.
31
46
  def revealed
32
47
  cells.each_with_index.flat_map do |row, row_index|
33
48
  row.each_index.select { reveal?(row_index, it) }.map { [row_index, it] }
@@ -40,21 +55,39 @@ module Raindown
40
55
  @cells ||= @padded_rows.map { |row| Array.new(width_in_cells) { MarkdownTable.cell(row[it].to_s) } }
41
56
  end
42
57
 
58
+ def each_cell
59
+ cells.each_with_index.map { |row, row_index| row.each_with_index.map { |cell, column| yield cell, row_index, column } }
60
+ end
61
+
43
62
  # As wide as its widest row, as its file's rows are read, so a row still being typed reads the same in both.
44
63
  def width_in_cells
45
64
  @padded_rows.map(&:length).max
46
65
  end
47
66
 
48
- def reveal?(row, column)
49
- formula = @file.dig(row, column)
50
- return false unless TableFormulas.formula?(formula)
67
+ def file_cell(row, column)
68
+ file_rows[row]&.then { cell_in_file(it, column) }
69
+ end
51
70
 
52
- cell = cells[row][column]
53
- same_formula?(cell, formula) || (cell == @values.dig(row, column) && squeezed?(row, column))
71
+ def cell_in_file(file_row, column)
72
+ saved = @file.dig(file_row, column)
73
+ FileCell.new(saved:, value: @values.dig(file_row, column), column: @columns[column]) if saved
54
74
  end
55
75
 
56
- def same_formula?(cell, formula)
57
- TableFormulas.formula?(formula) && cell.gsub(/\s+/, '') == formula.gsub(/\s+/, '')
76
+ # The index of each of its rows' file row, or nil for a row that's new.
77
+ def file_rows
78
+ @file_rows ||= MatchedRows.new(cells.length, @file.length) { |row, file_row| same_row?(row, file_row) }.file_rows
79
+ end
80
+
81
+ # Whether the table's row shows the file's row, each cell unchanged, or blank where the file has none.
82
+ def same_row?(row, file_row)
83
+ cells[row].each_with_index.all? do |cell, column|
84
+ cell_in_file(file_row, column)&.unchanged_by?(cell) || (cell.empty? && !@file.dig(file_row, column))
85
+ end
86
+ end
87
+
88
+ def reveal?(row, column)
89
+ padded = @padded_rows[row][column].to_s
90
+ file_cell(row, column)&.revealed_by?(cells[row][column], squeezed: squeezed?(row, column), wrapped: padded.include?("\n"))
58
91
  end
59
92
 
60
93
  def squeezed?(row, column)
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ # What a node's rewrite writes below its directive: its Markdown, and a comment straight below the directive, like
5
+ # table:'s "2 rows · 3f2a9c", which its rewrite gets back as comment: the next time it writes. A rewrite can return a
6
+ # string instead, when it has no comment.
7
+ class Written
8
+ attr_reader :markdown, :comment
9
+
10
+ def initialize(markdown, comment: nil)
11
+ @markdown = markdown
12
+ @comment = comment
13
+ end
14
+
15
+ # Its Markdown, as a rewrite's string would be, like when it's rendered as HTML.
16
+ def to_s
17
+ markdown.to_s
18
+ end
19
+ end
20
+ end
data/lib/raindown.rb CHANGED
@@ -16,6 +16,7 @@ require_relative 'raindown/heading_ids'
16
16
  require_relative 'raindown/node_blocks'
17
17
  require_relative 'raindown/pages'
18
18
  require_relative 'raindown/pre_renderer'
19
+ require_relative 'raindown/table/table_formatter'
19
20
  require_relative 'raindown/utils'
20
21
  require_relative 'raindown/variables'
21
22
  require_relative 'raindown/wiki_links'
@@ -89,7 +90,7 @@ module Raindown
89
90
 
90
91
  # Markdown to HTML, like a document's or a component's slot.
91
92
  def commonmark(markdown)
92
- doc = Commonmarker.parse(hide_directives(markdown:), options: MARKDOWN_OPTIONS)
93
+ doc = Commonmarker.parse(hide_directives(markdown: TableFormatter.gfm(markdown)), options: MARKDOWN_OPTIONS)
93
94
  preserve_code(doc:)
94
95
 
95
96
  html = highlight_code? ? doc.to_html(options: HTML_OPTIONS) : unhighlighted_html(doc:)
data/lib/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raindown
4
- VERSION = '0.17.2'
4
+ VERSION = '0.18.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.17.2
4
+ version: 0.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - maedi
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-11-08 00:00:00.000000000 Z
10
+ date: 2026-11-09 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: antlers
@@ -254,8 +254,11 @@ files:
254
254
  - lib/raindown/server.rb
255
255
  - lib/raindown/skeleton.rb
256
256
  - lib/raindown/stat.rb
257
+ - lib/raindown/table/file_cell.rb
257
258
  - lib/raindown/table/markdown_table.rb
259
+ - lib/raindown/table/matched_rows.rb
258
260
  - lib/raindown/table/table.rb
261
+ - lib/raindown/table/table_column.rb
259
262
  - lib/raindown/table/table_file.rb
260
263
  - lib/raindown/table/table_formatter.rb
261
264
  - lib/raindown/table/table_formulas.rb
@@ -339,6 +342,7 @@ files:
339
342
  - lib/raindown/wiki_links.rb
340
343
  - lib/raindown/workers/worker.rb
341
344
  - lib/raindown/workers/workers.rb
345
+ - lib/raindown/written.rb
342
346
  - lib/version.rb
343
347
  homepage: https://github.com/rainpour/raindown
344
348
  licenses: []