raindown 0.17.1 → 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 +4 -4
- data/lib/raindown/chart/chart.rb +3 -1
- data/lib/raindown/chart/points.rb +1 -1
- data/lib/raindown/cli.rb +3 -2
- data/lib/raindown/editing.rb +1 -1
- data/lib/raindown/node_blocks.rb +34 -7
- data/lib/raindown/node_injector.rb +44 -3
- data/lib/raindown/nodes.rb +1 -0
- data/lib/raindown/problem.rb +34 -0
- data/lib/raindown/rewriter.rb +7 -4
- data/lib/raindown/table/file_cell.rb +83 -0
- data/lib/raindown/table/markdown_table.rb +51 -10
- data/lib/raindown/table/matched_rows.rb +71 -0
- data/lib/raindown/table/table.rb +47 -46
- data/lib/raindown/table/table_column.rb +115 -0
- data/lib/raindown/table/table_file.rb +50 -0
- data/lib/raindown/table/table_formatter.rb +63 -67
- data/lib/raindown/table/written_table.rb +49 -16
- data/lib/raindown/watcher/file_watcher.rb +1 -0
- data/lib/raindown/watcher/project.rb +7 -1
- data/lib/raindown/written.rb +20 -0
- data/lib/raindown.rb +2 -1
- data/lib/version.rb +1 -1
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: baf58826a9f492870795c2cfb36104ffb214b7494c0b14a2b11aa5f5a7711c64
|
|
4
|
+
data.tar.gz: 136d56da8dd7574698769f971bcce51662c89f29b28e2b87acb758017f9ee708
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2ceb319ab5a5c1482a6d09f98b75e95bf22ece5ca8477b54621ee542691c6de3ee243dc0ef3f580971167cb116990a89a28ae5a4012b50c16ec659a8fcdf5cfa
|
|
7
|
+
data.tar.gz: 411f712343878e32a1efc7d99932254984e3f0dc45ba91a8b41316fcbf61e9aa0c404ea7d66fc75e0293975d60632e28bde04adcd044d8e3dc36c02ddf9390c2
|
data/lib/raindown/chart/chart.rb
CHANGED
|
@@ -33,12 +33,14 @@ module Raindown
|
|
|
33
33
|
|
|
34
34
|
# The data is read every time, so the chart follows changes to its file.
|
|
35
35
|
def rewrite(metadata: NoMetadata)
|
|
36
|
+
return Problem.alert('Needs a file path to a CSV, TSV, JSON or YAML file') if ['', nil].include?(@data)
|
|
37
|
+
|
|
36
38
|
points = points(metadata:)
|
|
37
39
|
return '' if points.empty?
|
|
38
40
|
|
|
39
41
|
"```\n#{draw(points).map(&:rstrip).join("\n")}\n```"
|
|
40
42
|
rescue Error => e
|
|
41
|
-
"Can't chart #{@data.inspect}: #{e.message}"
|
|
43
|
+
Problem.alert("Can't chart #{@data.inspect}: #{e.message}")
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
# Each value with its label, or nil for numbers alone.
|
data/lib/raindown/cli.rb
CHANGED
|
@@ -169,9 +169,10 @@ module Raindown
|
|
|
169
169
|
Nodes.load(path)
|
|
170
170
|
|
|
171
171
|
metadata = Pages.for(path)
|
|
172
|
-
|
|
172
|
+
rewrite = Rewriter.update(path:, bare:, execute:, metadata:)
|
|
173
|
+
warn(*rewrite.problems.map { "raindown: #{path}: #{it}" })
|
|
173
174
|
write(output, Raindown.render_file(path:, bare:, metadata:))
|
|
174
|
-
check_endpoints(failures)
|
|
175
|
+
check_endpoints(rewrite.failures)
|
|
175
176
|
end
|
|
176
177
|
|
|
177
178
|
# Endpoint responses that fail their checks, like status: 200, are listed, and fail the command, for CI.
|
data/lib/raindown/editing.rb
CHANGED
|
@@ -18,7 +18,7 @@ module Raindown
|
|
|
18
18
|
def rewrite_markdown(markdown, path:, bare: false, execute: false)
|
|
19
19
|
Nodes.load(path)
|
|
20
20
|
rewrite = Rewriter.rewrite(content: markdown, path:, bare:, execute:, metadata: Pages.for(path))
|
|
21
|
-
{ output: rewrite.content, warnings: rewrite.failures.map { "Endpoint check failed: #{it}" } }
|
|
21
|
+
{ output: rewrite.content, warnings: rewrite.failures.map { "Endpoint check failed: #{it}" } + rewrite.problems }
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
24
|
end
|
data/lib/raindown/node_blocks.rb
CHANGED
|
@@ -31,13 +31,15 @@ module Raindown
|
|
|
31
31
|
# <{ Card }>, or <{ :Card }>, which a lowercase name like toc needs, as <{ toc }> is a variable.
|
|
32
32
|
SELF_CLOSING = /\A( {0,3})<\{\s*(?::|(?=[A-Z]))(#{NAME})(?:\s+(.*?))?\s*\}>[ \t]*$/
|
|
33
33
|
BARE_SELF_CLOSING = /\A( {0,3}):(#{NAME})(?:[ \t]+(.*?))?[ \t]*$/
|
|
34
|
-
# Unquoted text after name:, like YAML's, runs up to the next prop, so title: Hello world limit: 3 has two.
|
|
35
|
-
|
|
34
|
+
# Unquoted text after name:, like YAML's, runs up to the next prop, so title: Hello world limit: 3 has two. A comma
|
|
35
|
+
# before the next prop, like data.csv, total: true, separates them like a space, while one in text, like Hello,
|
|
36
|
+
# world, is part of it.
|
|
37
|
+
UNQUOTED = /.+?(?=(?:,[ \t]*|[ \t]+)\w+(?:=|:[ \t])|,?[ \t]*\z)/
|
|
36
38
|
# A prop is name=value or name: value, where the value is quoted text, a number, true, false
|
|
37
|
-
# or a variable like author.name. After name= it's one word.
|
|
38
|
-
PROP = /(\w+)(?:=|:[ \t]+)("[^"]*"|'[^']*'|(?<==)\S
|
|
39
|
+
# or a variable like author.name. After name= it's one word, without a comma after it.
|
|
40
|
+
PROP = /(\w+)(?:=|:[ \t]+)("[^"]*"|'[^']*'|(?<==)\S+?(?=,?(?:\s|\z))|#{UNQUOTED})/
|
|
39
41
|
# A value before the props is the node's argument.
|
|
40
|
-
ARG = /\A("[^"]*"|'[^']*'|[^\s"']#{UNQUOTED}|[^\s"'])(
|
|
42
|
+
ARG = /\A("[^"]*"|'[^']*'|[^\s"']#{UNQUOTED}|[^\s"'])(?:,\s*|\s+|\z)/
|
|
41
43
|
|
|
42
44
|
module_function
|
|
43
45
|
|
|
@@ -51,11 +53,33 @@ module Raindown
|
|
|
51
53
|
variables = Variables.new(front_matter)
|
|
52
54
|
|
|
53
55
|
spans.map do |span|
|
|
54
|
-
node =
|
|
56
|
+
node = node_for(span, args(span[:args], variables, antlers: span[:antlers]))
|
|
55
57
|
span.except(:name, :class, :args, :antlers).merge(node:)
|
|
56
58
|
end
|
|
57
59
|
end
|
|
58
60
|
|
|
61
|
+
# The block's node, or a Problem in its place when it can't be made, so the rest of the file still writes back.
|
|
62
|
+
def node_for(span, (props, values))
|
|
63
|
+
problem = values_problem(span, values || [])
|
|
64
|
+
return Nodes.instance(Problem, {}, [problem, span[:open] + 1, span[:name]]) if problem
|
|
65
|
+
|
|
66
|
+
Nodes.instance(span[:class], props, *[values].compact)
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
Nodes.instance(Problem, {}, ["Couldn't start: #{e.message}", span[:open] + 1, span[:name]])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A directive with more or fewer values than its node takes, like toc: extra, rather than Ruby's wrong number of
|
|
72
|
+
# arguments, or nil when it has as many as it takes.
|
|
73
|
+
def values_problem(span, values)
|
|
74
|
+
kinds = span[:class].instance_method(:initialize).parameters.map(&:first)
|
|
75
|
+
required = kinds.count(:req)
|
|
76
|
+
return if kinds.include?(:rest) || values.length.between?(required, required + kinds.count(:opt))
|
|
77
|
+
|
|
78
|
+
return 'Needs a value after it' if values.length < required
|
|
79
|
+
|
|
80
|
+
"Takes no value, so it can't read #{values.first.inspect}"
|
|
81
|
+
end
|
|
82
|
+
|
|
59
83
|
# Each block like find's, with the node's name, class and props text in place of the node,
|
|
60
84
|
# so a block can be found without making its node, like for a document's outline.
|
|
61
85
|
def spans(lines, bare: false)
|
|
@@ -95,10 +119,13 @@ module Raindown
|
|
|
95
119
|
[marker(lines[block[:open]]), *("#{output}\n".lines unless output.empty?), *close]
|
|
96
120
|
end
|
|
97
121
|
|
|
122
|
+
# A node that raises shows why in its place, rather than stopping the page.
|
|
98
123
|
def node_markdown(block, context)
|
|
99
124
|
return Nodes.rendered(block[:node], context) if !block[:writes] || Nodes.html?(block[:node])
|
|
100
125
|
|
|
101
126
|
Nodes.call(block[:node], :rewrite, **context.to_h).to_s
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
Problem.alert("Couldn't write: #{e.message}")
|
|
102
129
|
end
|
|
103
130
|
|
|
104
131
|
def marker(line)
|
|
@@ -155,7 +182,7 @@ module Raindown
|
|
|
155
182
|
|
|
156
183
|
# Text that isn't a prop, like world in title: 'Hello' world or tag=a b, is an error rather than left out.
|
|
157
184
|
def props(text, variables, antlers: true)
|
|
158
|
-
unread = text.gsub(PROP, '').split
|
|
185
|
+
unread = text.gsub(PROP, '').delete(',').split
|
|
159
186
|
raise BrokenText, "Can't read #{unread.join(' ')} in #{text.strip}. Text with spaces needs quotes" if unread.any?
|
|
160
187
|
|
|
161
188
|
text.scan(PROP).to_h { |name, value| [name.to_sym, value(value, variables, antlers:)] }
|
|
@@ -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,13 +12,22 @@ 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
|
|
17
22
|
end
|
|
18
23
|
|
|
24
|
+
# Warnings for the directives that couldn't run, written in their place, each with its line, like
|
|
25
|
+
# "line 6: toc: takes no value".
|
|
26
|
+
attr_reader :problems
|
|
27
|
+
|
|
19
28
|
def initialize(content:, path: nil, bare: false, metadata: NoMetadata)
|
|
20
29
|
@content = content
|
|
30
|
+
@problems = []
|
|
21
31
|
@bare = BareDirectives.enabled?(path:, content:, bare:)
|
|
22
32
|
@metadata = metadata
|
|
23
33
|
end
|
|
@@ -28,21 +38,36 @@ module Raindown
|
|
|
28
38
|
.select { |block| block[:writes] }
|
|
29
39
|
return @content if blocks.empty?
|
|
30
40
|
|
|
41
|
+
# From the bottom up, so the lines above each block stay where they are, so its problems are put back in order.
|
|
31
42
|
blocks.reverse_each { |block| lines[block[:open]..block[:last]] = block_lines(lines, block) }
|
|
43
|
+
@problems.reverse!
|
|
32
44
|
lines.join
|
|
33
45
|
end
|
|
34
46
|
|
|
35
47
|
private
|
|
36
48
|
|
|
37
49
|
def block_lines(lines, block)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
written =
|
|
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))
|
|
41
53
|
return [line(lines[block[:open]]), *bare(written, block)] unless block[:close]
|
|
42
54
|
|
|
43
55
|
delimited(lines, block, written)
|
|
44
56
|
end
|
|
45
57
|
|
|
58
|
+
# A node that raises writes why in its place, like a Problem, so the rest of the file still writes back.
|
|
59
|
+
def node_written(block, context)
|
|
60
|
+
@problems << block[:node].warning if block[:node].is_a?(Problem)
|
|
61
|
+
written = Nodes.call(block[:node], :rewrite, **context)
|
|
62
|
+
return written if written.is_a?(Written)
|
|
63
|
+
|
|
64
|
+
Written.new(written.to_s)
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
problem = Problem.new("Couldn't write: #{e.message}", block[:open] + 1)
|
|
67
|
+
@problems << problem.warning
|
|
68
|
+
Written.new(problem.rewrite)
|
|
69
|
+
end
|
|
70
|
+
|
|
46
71
|
# The directive, what the node wrote and the end tag, straight after each other, as a comment on its own line ends
|
|
47
72
|
# at its -->, so what's written below it, like a list, is its own block.
|
|
48
73
|
def delimited(lines, block, written) = [line(lines[block[:open]]), *written, line(lines[block[:close]])]
|
|
@@ -55,6 +80,22 @@ module Raindown
|
|
|
55
80
|
raise BlankLine, "#{name} writes blank lines, so it needs <!-- #{name}: --> and <!-- :#{name} --> around it"
|
|
56
81
|
end
|
|
57
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
|
+
|
|
58
99
|
# What the block's node wrote last time, between its directive and its end tag, or the next blank line.
|
|
59
100
|
def written_lines(lines, block)
|
|
60
101
|
lines[(block[:open] + 1)...(block[:close] || (block[:last] + 1))]
|
data/lib/raindown/nodes.rb
CHANGED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'node'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
# What a directive writes in place of its output when it can't run, like toc: extra, or a node that raised, so the
|
|
7
|
+
# rest of the file still writes back. In the file it's under its directive, so only its warning says its line and
|
|
8
|
+
# name. It never starts with the name, like toc:, which would read as a directive once the one above it's deleted.
|
|
9
|
+
class Problem < Node
|
|
10
|
+
attr_reader :message, :line
|
|
11
|
+
|
|
12
|
+
def initialize(message, line, name = nil)
|
|
13
|
+
@message = message
|
|
14
|
+
@line = line
|
|
15
|
+
@name = name
|
|
16
|
+
super()
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# A message as a Markdown alert, which Obsidian, GitHub and Raindown show as a warning, and which can't read as a
|
|
20
|
+
# directive, as it starts with >.
|
|
21
|
+
def self.alert(message)
|
|
22
|
+
"> [!WARNING]\n#{message.lines.map { "> #{it}" }.join}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def rewrite
|
|
26
|
+
self.class.alert(@message)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# For a terminal, or an editor to underline the line, like "line 6: toc: takes no value".
|
|
30
|
+
def warning
|
|
31
|
+
["line #{@line}", @name, @message.sub(/\A[A-Z](?![A-Z])/, &:downcase)].compact.join(': ')
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/raindown/rewriter.rb
CHANGED
|
@@ -9,8 +9,9 @@ module Raindown
|
|
|
9
9
|
# Rewrites the generated parts of a Markdown file in place before it's rendered:
|
|
10
10
|
# node output, like the TOC, endpoint responses and table alignment.
|
|
11
11
|
module Rewriter
|
|
12
|
-
# The content written back,
|
|
13
|
-
|
|
12
|
+
# The content written back, the endpoint checks it failed, like "api.md:3: expected status 200, received 404", and
|
|
13
|
+
# the directives that couldn't run, like "line 4: toc: takes no value", written in their place.
|
|
14
|
+
Rewrite = Data.define(:original, :content, :failures, :problems) do
|
|
14
15
|
def changed?
|
|
15
16
|
content != original
|
|
16
17
|
end
|
|
@@ -19,9 +20,11 @@ module Raindown
|
|
|
19
20
|
module_function
|
|
20
21
|
|
|
21
22
|
def rewrite(content:, path:, bare: false, execute: false, metadata: NoMetadata)
|
|
22
|
-
|
|
23
|
+
injector = NodeInjector.new(content:, path:, bare:, metadata:)
|
|
24
|
+
injected = injector.call
|
|
23
25
|
endpoints = EndpointRunner.new(content: injected, path:, bare: BareDirectives.enabled?(path:, content: injected, bare:), execute:)
|
|
24
|
-
Rewrite.new(original: content, content: TableFormatter.rewrite(content: endpoints.rewrite), failures: endpoints.failures
|
|
26
|
+
Rewrite.new(original: content, content: TableFormatter.rewrite(content: endpoints.rewrite), failures: endpoints.failures,
|
|
27
|
+
problems: injector.problems)
|
|
25
28
|
end
|
|
26
29
|
|
|
27
30
|
# Writes the file only when it changed.
|
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
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
|
-
|
|
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
|