raindown 0.15.0 → 0.16.1

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: 3d201e22c68a6326752cd504b5cca6674384031244b3b91cbe9e8da2e1034d8b
4
- data.tar.gz: 6b3cf1bbc8e03c353c0eb0bade6a7c1f9546c99fc2dfabc260963f50e7ff3063
3
+ metadata.gz: 3f181e2b7e348aefd77d7f22eef4aee64e16022ba3050d04493b829fbdabff7b
4
+ data.tar.gz: 9388ac597b48b784229155e26a96c535b67eb6ff84a277450d1c43d4e117b004
5
5
  SHA512:
6
- metadata.gz: cb8dba488c6c4ca2ddd49f8cd699a3d660f9a12fb00e756e6f5cd65e433b2ac04d4d4124e078a41b39fbe2707632f5cc1a78acf97e033dfdbe5715c01e50985b
7
- data.tar.gz: 4d1d7f48804fcdc7ee6ce0343d2f34fa0f797566dc222a645dae1d3f4b79cc9f7841c6acbbfb83c93c0dde681bf110139f9ed51b4fd7ef7c37181db1052137fe
6
+ metadata.gz: 6f0c4bd5677aa6f16058b8528f6af05f4ced2d4f8e4107225ae549e4d1c01e001342e45b8acf3f4e853034692094bcc684c5c8fc89d614ce39651e8307dbafc7
7
+ data.tar.gz: e2d10247dc4718f13909849c12feeed14a8c78e73e3de470908a3ed5684b36e0d4663c7cdbfaf45ba10a41a204f169922482739c3c81b49537df17d77a383ed4
@@ -5,6 +5,7 @@ require 'json'
5
5
  require 'yaml'
6
6
 
7
7
  require_relative '../node'
8
+ require_relative '../utils'
8
9
 
9
10
  module Raindown
10
11
  class Chart < Node
@@ -43,6 +44,7 @@ module Raindown
43
44
  return numbers(numbers) if numbers.any? && numbers.all? { number(it) }
44
45
 
45
46
  path = File.expand_path(string, @root)
47
+ raise Error, "it's outside the project" unless Utils.inside?(path, @root)
46
48
  raise Error, 'no such file' unless File.file?(path)
47
49
 
48
50
  from(read(path))
@@ -3,6 +3,8 @@
3
3
  require 'date'
4
4
  require 'yaml'
5
5
 
6
+ require_relative 'utils'
7
+
6
8
  module Raindown
7
9
  # YAML between two --- lines at the very start of a file.
8
10
  module FrontMatter
@@ -19,6 +21,14 @@ module Raindown
19
21
  [metadata.is_a?(Hash) ? metadata : {}, match.post_match]
20
22
  end
21
23
 
24
+ # A file's metadata, or none when it can't be read, with a warning naming it, so one file can't stop a list of them.
25
+ def read(file_path)
26
+ parse(Utils.read_markdown(file_path)).first
27
+ rescue Psych::Exception, ArgumentError => e
28
+ warn "raindown: #{file_path}: #{e.message}, so it's listed without its front matter"
29
+ {}
30
+ end
31
+
22
32
  # How many of the lines are front matter, so directives in its YAML are left alone.
23
33
  def line_count(lines)
24
34
  lines.join.match(PATTERN)&.then { |match| match[0].lines.length } || 0
data/lib/raindown/list.rb CHANGED
@@ -14,18 +14,26 @@ module Raindown
14
14
  @tags = tags
15
15
  end
16
16
 
17
- # Each file has its front matter, its path and its content. Sorted by an order, files without it come last, and ones
18
- # with the same value stay in the order they're listed.
17
+ # Each file has its front matter, its path and its content. Sorted by an order, numbers come first, then other values
18
+ # as text, then files without it, and ones with the same value stay in the order they're listed.
19
19
  def files(metadata: NoMetadata)
20
20
  files = metadata.list(**@tags)
21
21
  return files unless @order
22
22
 
23
23
  sorted, unsorted = files.partition { it[@order] }
24
- sorted.sort_by.with_index { |file, index| [file[@order], index] } + unsorted
24
+ sorted.sort_by.with_index { |file, index| [*order_key(file[@order]), index] } + unsorted
25
25
  end
26
26
 
27
27
  def rewrite(metadata: NoMetadata)
28
- files(metadata:).map { |file| "- [#{file.title || file.path}](#{file.path})" }.join("\n")
28
+ files(metadata:).map { |file| "- [#{file.title || File.basename(file.path, '.*')}](#{file.path})" }.join("\n")
29
+ end
30
+
31
+ private
32
+
33
+ def order_key(value)
34
+ return [0, value, ''] if value.is_a?(Numeric)
35
+
36
+ [1, 0, value.to_s]
29
37
  end
30
38
  end
31
39
  end
@@ -25,10 +25,11 @@ module Raindown
25
25
  @front_matters ||= {}
26
26
  end
27
27
 
28
- # The Markdown files under a project's root, the folder with its .raindown folder or else the file's folder.
28
+ # The Markdown files under a project's root, the folder with its .raindown folder, or a vault, or else the file's
29
+ # folder.
29
30
  def for(path)
30
- file_directory = File.dirname(File.expand_path(path))
31
- load(root: Nodes.find_folder(file_directory)&.then { File.dirname(it) } || file_directory)
31
+ require_relative 'projects'
32
+ load(root: Projects.content_root(path) || File.dirname(File.expand_path(path)))
32
33
  end
33
34
 
34
35
  # Lists the files under the root, without loading them, for Dirload.
@@ -118,7 +119,7 @@ module Raindown
118
119
  modified_then, front_matter = self.class.front_matters[file_path]
119
120
  return front_matter if modified_then == modified
120
121
 
121
- FrontMatter.parse(Utils.read_markdown(file_path)).first.tap { self.class.front_matters[file_path] = [modified, it] }
122
+ FrontMatter.read(file_path).tap { self.class.front_matters[file_path] = [modified, it] }
122
123
  end
123
124
 
124
125
  # The path is relative to the root, like docs/_basics/_1-getting-started.md.
@@ -44,6 +44,18 @@ module Raindown
44
44
  folder && File.dirname(folder)
45
45
  end
46
46
 
47
+ # The folder a file's paths are relative to: the nearest with a .raindown folder or that's a vault, or nil without one.
48
+ # A vault doesn't need a .raindown folder, as Obsidian's links and a table's path are from its top.
49
+ def content_root(path)
50
+ folder = File.dirname(File.expand_path(path))
51
+ until project?(folder) || vault?(folder)
52
+ return if folder == File.dirname(folder)
53
+
54
+ folder = File.dirname(folder)
55
+ end
56
+ folder
57
+ end
58
+
47
59
  # The project's Markdown files, sorted, leaving out ignored folders. The projects inside it are left out too,
48
60
  # since they have nodes of their own, and vaults, which their editor writes back to.
49
61
  def files(root)
@@ -35,7 +35,12 @@ module Raindown
35
35
  end
36
36
 
37
37
  def escape(cell)
38
- cell.gsub('|', '\|').gsub(/\s*\R\s*/, ' ')
38
+ one_line(cell).gsub('|', '\|')
39
+ end
40
+
41
+ # The cell as a table shows it, on one line.
42
+ def one_line(cell)
43
+ cell.gsub(/\s*\R\s*/, ' ')
39
44
  end
40
45
  end
41
46
  end
@@ -5,6 +5,7 @@ require 'digest'
5
5
  require 'json'
6
6
 
7
7
  require_relative '../node'
8
+ require_relative '../utils'
8
9
  require_relative 'markdown_table'
9
10
  require_relative 'table_formulas'
10
11
  require_relative 'written_table'
@@ -36,24 +37,31 @@ module Raindown
36
37
  # Written is what it wrote last time, so it can tell which of the table and the file changed since.
37
38
  def rewrite(written: [], metadata: NoMetadata)
38
39
  path = data_path(metadata)
39
- data_error(path) || synced(path, written.map(&:chomp))
40
+ data_error(path, metadata) || synced(path, written.map(&:chomp))
40
41
  end
41
42
 
42
43
  # Write-in, in <{ }>, the table only shows its file, and never writes to it.
43
44
  def render(metadata: NoMetadata)
44
45
  path = data_path(metadata)
45
- Raindown.commonmark(data_error(path) || MarkdownTable.format(with_total(TableFormulas.evaluate(read_rows(path)))))
46
+ Raindown.commonmark(data_error(path, metadata) || MarkdownTable.format(with_total(TableFormulas.evaluate(read_rows(path)))))
46
47
  end
47
48
 
48
49
  private
49
50
 
50
51
  def data_path(metadata)
51
- File.expand_path(@data.to_s, metadata.respond_to?(:root) ? metadata.root : Dir.pwd)
52
+ File.expand_path(@data.to_s, data_root(metadata))
53
+ end
54
+
55
+ def data_root(metadata)
56
+ return metadata.root if metadata.respond_to?(:root)
57
+
58
+ Dir.pwd
52
59
  end
53
60
 
54
61
  # Why the file can't be a table, or nil when it can.
55
- def data_error(path)
62
+ def data_error(path, metadata)
56
63
  return "Can't show #{@data.inspect} as a table: only CSV and TSV files can be" unless SEPARATORS.key?(File.extname(path).downcase)
64
+ return "Can't show #{@data.inspect} as a table: it's outside the project" unless Utils.inside?(path, data_root(metadata))
57
65
 
58
66
  "Can't show #{@data.inspect} as a table: no such file" unless File.file?(path)
59
67
  end
@@ -64,7 +72,7 @@ module Raindown
64
72
  synced_hash = written.first&.[](COMMENT, 1)
65
73
  table = WrittenTable.new(written_rows(written), file)
66
74
  return markdown(file, table.revealed) if unchanged?(table.rows, synced_hash, shown)
67
- return markdown(write_rows(path, with_formulas(table.rows, file, shown)), table.revealed) if hash_rows(shown) == synced_hash
75
+ return markdown(write_rows(path, with_file_cells(table.rows, file, shown)), table.revealed) if hash_rows(shown) == synced_hash
68
76
 
69
77
  warn "raindown: #{@data} and its table both changed, so neither is written until they match"
70
78
  ["<!-- table conflict · #{synced_hash} -->", *written.drop(1)].join("\n")
@@ -95,12 +103,13 @@ module Raindown
95
103
  revealed.each_with_object(values.map(&:dup)) { |(row, column), shown| shown[row][column] = rows[row][column] }
96
104
  end
97
105
 
98
- # The table's rows, keeping the file's formula in each cell still showing its value.
99
- def with_formulas(table, file, shown)
106
+ # The table's rows, keeping the file's cell where the table still shows it, like a formula as its value, or a cell
107
+ # with line breaks on one line.
108
+ def with_file_cells(table, file, shown)
100
109
  table.each_with_index.map do |row, row_index|
101
110
  row.each_with_index.map do |cell, column|
102
- formula = file.dig(row_index, column)
103
- next formula if TableFormulas.formula?(formula) && shown.dig(row_index, column) == cell
111
+ kept = file.dig(row_index, column)
112
+ next kept if kept && MarkdownTable.one_line(shown.dig(row_index, column).to_s) == cell
104
113
 
105
114
  cell
106
115
  end
@@ -146,8 +155,9 @@ module Raindown
146
155
  rows
147
156
  end
148
157
 
158
+ # As the table shows them, so a cell with line breaks hashes the same in both.
149
159
  def hash_rows(rows)
150
- Digest::SHA256.hexdigest(JSON.generate(rows))[0, 6]
160
+ Digest::SHA256.hexdigest(JSON.generate(rows.map { |row| row.map { MarkdownTable.one_line(it.to_s) } }))[0, 6]
151
161
  end
152
162
  end
153
163
  end
data/lib/raindown/toc.rb CHANGED
@@ -15,7 +15,7 @@ module Raindown
15
15
  TAG = /<[^>]*>/
16
16
 
17
17
  def rewrite(document:, metadata: nil, bare: false)
18
- headings = headings(markdown: document, metadata:, bare:)
18
+ headings = headings_below_title(markdown: document, metadata:, bare:)
19
19
  return '' if headings.empty?
20
20
 
21
21
  min_level = headings.map { |heading| heading[:level] }.min
@@ -27,7 +27,7 @@ module Raindown
27
27
 
28
28
  # With the document's final HTML, the headings include ones its components render.
29
29
  def render(document:, html: nil, metadata: nil, bare: false)
30
- items = headings(markdown: document, html:, metadata:, bare:).map do |heading|
30
+ items = headings_below_title(markdown: document, html:, metadata:, bare:).map do |heading|
31
31
  "<li class='h#{heading[:level]}'><a href='##{heading[:id]}'>#{CGI.escapeHTML(heading[:text])}</a></li>"
32
32
  end
33
33
 
@@ -43,6 +43,14 @@ module Raindown
43
43
  HTML
44
44
  end
45
45
 
46
+ # A lone top-level heading that starts the document is its title, so it's left out when there are headings below it.
47
+ def headings_below_title(**)
48
+ title, *below = headings = headings(**)
49
+ return headings if below.empty? || title[:level] != 1 || below.any? { it[:level] == 1 }
50
+
51
+ below
52
+ end
53
+
46
54
  # The document's headings, from its final HTML, or else by rendering it with its components.
47
55
  def headings(markdown:, html: nil, metadata: nil, bare: false)
48
56
  html ||= Fragments.collect { Raindown.assembled(Context.new(document: markdown, metadata: metadata || NoMetadata, bare:)) }
@@ -4,6 +4,11 @@ module Raindown
4
4
  module Utils
5
5
  module_function
6
6
 
7
+ # Whether a path is below a folder, so a note can't reach a file outside its project, like '../../secrets.csv'.
8
+ def inside?(path, folder)
9
+ File.expand_path(path).start_with?(File.join(File.expand_path(folder), ''))
10
+ end
11
+
7
12
  # A Markdown file's text, with its line endings as LF, "\n", whatever they are in the file.
8
13
  #
9
14
  # Windows ends lines with CRLF, "\r\n", and Git for Windows checks files out that way, so a file written or cloned on
data/lib/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raindown
4
- VERSION = '0.15.0'
4
+ VERSION = '0.16.1'
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.15.0
4
+ version: 0.16.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - maedi
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-11-06 00:00:00.000000000 Z
10
+ date: 2026-11-07 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: antlers