raindown 0.17.0 → 0.17.2

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: 2abf28897b1c392d34ec786d7abad550414050fe3f45949929240fa31453cf23
4
- data.tar.gz: edafdc330bddeaf1b76a4d6538b1e3db9241c1758526d0076b0ffe76d5aac32a
3
+ metadata.gz: ce48803b10f9d818849d224607930c3f41c63d66a830e9da2fa6ef43c877a5cb
4
+ data.tar.gz: 040a45c39fc4d1439d80af01c7c6d9d937d24850f69093c9e478c79964305bb6
5
5
  SHA512:
6
- metadata.gz: 5ce17ac9171dfa2d3a37311698f53e89390eccd2d3105a60adef58136f6bf4f741162ecd6eea0edc10a341434aaa58b1b8832e1971cc1ae3498771255616e3ce
7
- data.tar.gz: 15ff81a860a3403f8bca98ca63052be5d8ff1cc9feda5da235f6b753ffd92f74ba313471ac60d7b61c59fb7c84f38bd05e8dd7dfa68877a66d84bd5251499217
6
+ metadata.gz: 1e1109ca3ea21b6ae214b5d3bff967d2936e53b23ea0c71b1ca97bbcc208ac4249f5b14d151fb9a94f575d56fbdbfde12ccba1c12bf1b4a287ab632508df1260
7
+ data.tar.gz: 22ba4b827f93a2c9edaf6d21c7be2e8d6916b5a95627e2f29b1309eec01eb6de59cb126ab9874a725ca3b089813529ec7b902501541c06661e5d87922ca9ee41
@@ -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.
@@ -51,7 +51,7 @@ module Raindown
51
51
  end
52
52
 
53
53
  def read(path)
54
- text = File.read(path, encoding: Encoding::UTF_8)
54
+ text = File.read(path, encoding: 'bom|utf-8')
55
55
 
56
56
  case File.extname(path).downcase
57
57
  when '.csv' then CSV.parse(text, skip_blanks: true)
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
- failures = Rewriter.update(path:, bare:, execute:, metadata:).failures
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.
@@ -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
@@ -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
- UNQUOTED = /.+?(?=[ \t]+\w+(?:=|:[ \t])|[ \t]*\z)/
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+|#{UNQUOTED})/
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"'])(?:\s+|\z)/
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 = Nodes.instance(span[:class], *args(span[:args], variables, antlers: span[:antlers]))
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:)] }
@@ -16,8 +16,13 @@ module Raindown
16
16
  new(content:, path:, bare:, metadata:).call
17
17
  end
18
18
 
19
+ # Warnings for the directives that couldn't run, written in their place, each with its line, like
20
+ # "line 6: toc: takes no value".
21
+ attr_reader :problems
22
+
19
23
  def initialize(content:, path: nil, bare: false, metadata: NoMetadata)
20
24
  @content = content
25
+ @problems = []
21
26
  @bare = BareDirectives.enabled?(path:, content:, bare:)
22
27
  @metadata = metadata
23
28
  end
@@ -28,7 +33,9 @@ module Raindown
28
33
  .select { |block| block[:writes] }
29
34
  return @content if blocks.empty?
30
35
 
36
+ # From the bottom up, so the lines above each block stay where they are, so its problems are put back in order.
31
37
  blocks.reverse_each { |block| lines[block[:open]..block[:last]] = block_lines(lines, block) }
38
+ @problems.reverse!
32
39
  lines.join
33
40
  end
34
41
 
@@ -36,13 +43,23 @@ module Raindown
36
43
 
37
44
  def block_lines(lines, block)
38
45
  context = { document: @content, metadata: @metadata, bare: @bare, written: written_lines(lines, block) }
39
- markdown = Nodes.call(block[:node], :rewrite, **context).to_s.chomp
46
+ markdown = node_markdown(block, context)
40
47
  written = markdown.empty? ? [] : "#{markdown}\n".lines
41
48
  return [line(lines[block[:open]]), *bare(written, block)] unless block[:close]
42
49
 
43
50
  delimited(lines, block, written)
44
51
  end
45
52
 
53
+ # 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)
55
+ @problems << block[:node].warning if block[:node].is_a?(Problem)
56
+ Nodes.call(block[:node], :rewrite, **context).to_s.chomp
57
+ rescue StandardError => e
58
+ problem = Problem.new("Couldn't write: #{e.message}", block[:open] + 1)
59
+ @problems << problem.warning
60
+ problem.rewrite
61
+ end
62
+
46
63
  # The directive, what the node wrote and the end tag, straight after each other, as a comment on its own line ends
47
64
  # at its -->, so what's written below it, like a list, is its own block.
48
65
  def delimited(lines, block, written) = [line(lines[block[:open]]), *written, line(lines[block[:close]])]
@@ -6,6 +6,7 @@ require_relative 'js/js_nodes'
6
6
  require_relative 'list'
7
7
  require_relative 'markdown_nodes'
8
8
  require_relative 'node'
9
+ require_relative 'problem'
9
10
  require_relative 'stat'
10
11
  require_relative 'table/table'
11
12
  require_relative 'toc'
@@ -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
@@ -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, and the endpoint checks it failed, like "api.md:3: expected status 200, received 404".
13
- Rewrite = Data.define(:original, :content, :failures) do
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
- injected = NodeInjector.rewrite(content:, path:, bare:, metadata:)
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.
@@ -13,7 +13,7 @@ module Raindown
13
13
  # {"id": 1, "path": "/notes/a.md", "markdown": "<!-- toc: -->", "rewrite": true}
14
14
  # {"id": 1, "output": "<!-- toc: -->\n- ...", "warnings": []}
15
15
  # Rewriting answers like --stdin --rewrite, with "execute" like -x, and otherwise it's HTML, like --stdin. An error's
16
- # answered as {"id": 1, "error": "raindown: /notes/a.md: ..."}, like the CLI writes it.
16
+ # answered as {"id": 1, "error": "/notes/a.md: ..."}, like the CLI writes it.
17
17
  # Each project renders in a worker of its own, which starts again when its .raindown folder changes, so its nodes,
18
18
  # config and schemas are never stale. Without fork, as on Windows, node changes need the server started again.
19
19
  class Server
@@ -33,13 +33,14 @@ module Raindown
33
33
 
34
34
  private
35
35
 
36
+ # Any error is the answer, as an editor's only got its server, and one note can't be allowed to stop it.
36
37
  def answer(line)
37
38
  request = JSON.parse(line)
38
39
  { id: request['id'], **edit(request) }
39
- rescue JSON::ParserError, KeyError => e
40
- { id: request&.dig('id'), error: "raindown: #{e.message}" }
41
40
  rescue Workers::Worker::Error => e
42
- { id: request['id'], error: "raindown: #{request['path']}: #{e.message}" }
41
+ { id: request['id'], error: "#{request['path']}: #{e.message}" }
42
+ rescue StandardError => e
43
+ { id: (request['id'] if request.is_a?(Hash)), error: e.message.to_s }
43
44
  end
44
45
 
45
46
  def edit(request)
@@ -59,7 +60,7 @@ module Raindown
59
60
  end
60
61
 
61
62
  def nodes_changed?(project)
62
- stamp = Dir.glob(File.join(project, Nodes::FOLDER, '**', '*')).to_h { [it, File.mtime(it)] }
63
+ stamp = Dir.glob(File.join(project, Nodes::FOLDER, '**', '*')).to_h { [it, File.lstat(it).mtime] }
63
64
  changed = @stamps.key?(project) && @stamps[project] != stamp
64
65
  @stamps[project] = stamp
65
66
  changed
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'csv'
4
3
  require 'digest'
5
4
  require 'json'
6
5
 
7
6
  require_relative '../node'
8
7
  require_relative '../utils'
9
8
  require_relative 'markdown_table'
9
+ require_relative 'table_file'
10
10
  require_relative 'table_formulas'
11
11
  require_relative 'written_table'
12
12
 
@@ -26,24 +26,31 @@ module Raindown
26
26
  # the padding around one's value and it shows its formula, to edit, until it's changed.
27
27
  class Table < Node
28
28
  COMMENT = /\A<!-- table (?:conflict|\d+ rows?) · (\h+) -->\z/
29
- SEPARATORS = { '.csv' => ',', '.tsv' => "\t" }.freeze
30
29
  BOLD = /\A\*\*.*\*\*\z/
30
+ # The table a new file starts with, when there's no table to make it from.
31
+ STARTER = [['Column 1', 'Column 2'], ['', '']].freeze
31
32
 
32
- def initialize(data, total: false)
33
+ def initialize(data = nil, total: false)
33
34
  @data = data
34
35
  @total = total
35
36
  end
36
37
 
37
- # Written is what it wrote last time, so it can tell which of the table and the file changed since.
38
+ # 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
+ # not there yet is made, from the table below it, or else one to fill in.
38
40
  def rewrite(written: [], metadata: NoMetadata)
39
41
  path = data_path(metadata)
40
- data_error(path, metadata) || synced(path, written.map(&:chomp))
42
+ lines = written.map(&:chomp)
43
+ create_file(path, lines) if missing_file?(path, metadata)
44
+ data_error(path, metadata)&.then { Problem.alert(it) } || synced(path, lines)
41
45
  end
42
46
 
43
47
  # Write-in, in <{ }>, the table only shows its file, and never writes to it.
44
48
  def render(metadata: NoMetadata)
45
49
  path = data_path(metadata)
46
- Raindown.commonmark(data_error(path, metadata) || MarkdownTable.format(with_total(TableFormulas.evaluate(read_rows(path)))))
50
+ error = data_error(path, metadata)
51
+ return Raindown.commonmark(Problem.alert(error)) if error
52
+
53
+ Raindown.commonmark(MarkdownTable.format(with_total(TableFormulas.evaluate(TableFile.read(path)))))
47
54
  end
48
55
 
49
56
  private
@@ -58,21 +65,34 @@ module Raindown
58
65
  Dir.pwd
59
66
  end
60
67
 
68
+ def missing_file?(path, metadata)
69
+ TableFile.supported?(path) && Utils.inside?(path, data_root(metadata)) && !File.exist?(path)
70
+ end
71
+
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.
74
+ def create_file(path, lines)
75
+ rows = MarkdownTable.parse(lines.grep_v(COMMENT).grep_v(/\A>/))
76
+ rows = STARTER if rows.empty?
77
+ TableFile.create(path, rows)
78
+ end
79
+
61
80
  # Why the file can't be a table, or nil when it can.
62
81
  def data_error(path, metadata)
63
- return "Can't show #{@data.inspect} as a table: only CSV and TSV files can be" unless SEPARATORS.key?(File.extname(path).downcase)
82
+ return 'Needs a file path to a CSV/TSV that exists or will be created' if @data.to_s.strip.empty?
83
+ return "#{@data.inspect} must be a file path to a CSV/TSV that exists or will be created" unless TableFile.supported?(path)
64
84
  return "Can't show #{@data.inspect} as a table: it's outside the project" unless Utils.inside?(path, data_root(metadata))
65
85
 
66
86
  "Can't show #{@data.inspect} as a table: no such file" unless File.file?(path)
67
87
  end
68
88
 
69
89
  def synced(path, written)
70
- file = read_rows(path)
90
+ file = TableFile.read(path)
71
91
  shown = TableFormulas.evaluate(file)
72
92
  synced_hash = written.first&.[](COMMENT, 1)
73
93
  table = WrittenTable.new(written_rows(written), file)
74
94
  return markdown(file, table.revealed) if unchanged?(table.rows, synced_hash, shown)
75
- return markdown(write_rows(path, with_file_cells(table.rows, file, shown)), table.revealed) if hash_rows(shown) == synced_hash
95
+ return markdown(TableFile.write(path, with_file_cells(table.rows, file, shown)), table.revealed) if hash_rows(shown) == synced_hash
76
96
 
77
97
  warn "raindown: #{@data} and its table both changed, so neither is written until they match"
78
98
  ["<!-- table conflict · #{synced_hash} -->", *written.drop(1)].join("\n")
@@ -144,17 +164,6 @@ module Raindown
144
164
  row.any? { !it.empty? } && row.all? { it.empty? || it.match?(BOLD) }
145
165
  end
146
166
 
147
- def read_rows(path)
148
- rows = CSV.read(path, col_sep: SEPARATORS[File.extname(path).downcase], skip_blanks: true, encoding: Encoding::UTF_8)
149
- width = rows.map(&:length).max.to_i
150
- rows.map { |row| Array.new(width) { row[it].to_s.strip } }
151
- end
152
-
153
- def write_rows(path, rows)
154
- File.write(path, rows.map { CSV.generate_line(it, col_sep: SEPARATORS[File.extname(path).downcase]) }.join)
155
- rows
156
- end
157
-
158
167
  # As the table shows them, so a cell with line breaks hashes the same in both.
159
168
  def hash_rows(rows)
160
169
  Digest::SHA256.hexdigest(JSON.generate(rows.map { |row| row.map { MarkdownTable.one_line(it.to_s) } }))[0, 6]
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'csv'
4
+ require 'fileutils'
5
+
6
+ module Raindown
7
+ # The CSV or TSV file behind a table:, as rows of cells as wide as each other, written back in its own format.
8
+ module TableFile
9
+ SEPARATORS = { '.csv' => ',', '.tsv' => "\t" }.freeze
10
+ BYTE_ORDER_MARK = "\uFEFF"
11
+
12
+ module_function
13
+
14
+ def supported?(path)
15
+ SEPARATORS.key?(File.extname(path).downcase)
16
+ end
17
+
18
+ def read(path)
19
+ rows = CSV.read(path, col_sep: separator(path), skip_blanks: true, encoding: 'bom|utf-8')
20
+ width = rows.map(&:length).max.to_i
21
+ rows.map { |row| Array.new(width) { row[it].to_s.strip } }
22
+ end
23
+
24
+ # A new file, and the folders it's in, which has no format of its own to keep yet.
25
+ def create(path, rows)
26
+ FileUtils.mkdir_p(File.dirname(path))
27
+ File.binwrite(path, rows.map { CSV.generate_line(without_empty_cells(it), col_sep: separator(path)) }.join)
28
+ end
29
+
30
+ # Keeps the file's line endings and any byte order mark, like Excel's.
31
+ def write(path, rows)
32
+ text = File.binread(path).force_encoding(Encoding::UTF_8)
33
+ options = { col_sep: separator(path), row_sep: text[/\r?\n/] || "\n" }
34
+ lines = rows.map { CSV.generate_line(without_empty_cells(it), **options) }
35
+ File.binwrite(path, [(BYTE_ORDER_MARK if text.start_with?(BYTE_ORDER_MARK)), *lines].join)
36
+ rows
37
+ end
38
+
39
+ def separator(path)
40
+ SEPARATORS[File.extname(path).downcase]
41
+ end
42
+
43
+ # Empty cells as nil, which CSV leaves empty rather than quoting, but for a row of one, as it would be a blank line.
44
+ def without_empty_cells(row)
45
+ return row if row.one?
46
+
47
+ row.map { it unless it.empty? }
48
+ end
49
+ end
50
+ end
@@ -53,6 +53,7 @@ module Raindown
53
53
  def rewrite
54
54
  rewrite = Rewriter.update(path:, bare:, execute:, metadata: Pages.for(path))
55
55
  rewrite.failures.each { warn "Endpoint check failed: #{it}" }
56
+ rewrite.problems.each { warn "raindown: #{path}: #{it}" }
56
57
  rewrite.changed?
57
58
  end
58
59
  end
@@ -30,7 +30,7 @@ module Raindown
30
30
  def update(path)
31
31
  rewrite = Rewriter.update(path:, bare: @bare, execute: @execute, metadata: Pages.for(path))
32
32
  puts "[#{name}] Updated #{relative(path)}" if rewrite.changed?
33
- rewrite.failures.each { warn "[#{name}] Endpoint check failed: #{it}" }
33
+ report(rewrite, path)
34
34
  rescue StandardError => e
35
35
  raise if ENV['RAINDOWN_DEBUG']
36
36
 
@@ -40,6 +40,12 @@ module Raindown
40
40
 
41
41
  private
42
42
 
43
+ # What writing back found wrong: failed endpoint checks, and directives that couldn't run, written in their place.
44
+ def report(rewrite, path)
45
+ rewrite.failures.each { warn "[#{name}] Endpoint check failed: #{it}" }
46
+ rewrite.problems.each { warn "[#{name}] raindown: #{relative(path)}: #{it}" }
47
+ end
48
+
43
49
  def relative(path)
44
50
  File.expand_path(path).delete_prefix(File.join(root, ''))
45
51
  end
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.0'
4
+ VERSION = '0.17.2'
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.0
4
+ version: 0.17.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - maedi
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-11-07 00:00:00.000000000 Z
10
+ date: 2026-11-08 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: antlers
@@ -248,6 +248,7 @@ files:
248
248
  - lib/raindown/page.rb
249
249
  - lib/raindown/pages.rb
250
250
  - lib/raindown/pre_renderer.rb
251
+ - lib/raindown/problem.rb
251
252
  - lib/raindown/projects.rb
252
253
  - lib/raindown/rewriter.rb
253
254
  - lib/raindown/server.rb
@@ -255,6 +256,7 @@ files:
255
256
  - lib/raindown/stat.rb
256
257
  - lib/raindown/table/markdown_table.rb
257
258
  - lib/raindown/table/table.rb
259
+ - lib/raindown/table/table_file.rb
258
260
  - lib/raindown/table/table_formatter.rb
259
261
  - lib/raindown/table/table_formulas.rb
260
262
  - lib/raindown/table/written_table.rb