raindown 0.12.0 → 0.14.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/lib/elements.rb +12 -25
  3. data/lib/lexemes/node_lexeme.rb +37 -0
  4. data/lib/nodes/list_node.rb +22 -25
  5. data/lib/nodes/write_in_node.rb +95 -0
  6. data/lib/raindown/bare_directives.rb +11 -17
  7. data/lib/raindown/chart/chart.rb +1 -1
  8. data/lib/raindown/code_blocks.rb +23 -15
  9. data/lib/raindown/context.rb +19 -0
  10. data/lib/raindown/directives.rb +1 -1
  11. data/lib/raindown/document.rb +9 -9
  12. data/lib/raindown/endpoint/endpoint_runner.rb +15 -24
  13. data/lib/raindown/fragments.rb +6 -1
  14. data/lib/raindown/js/host.mjs +2 -2
  15. data/lib/raindown/js/js_nodes.rb +4 -4
  16. data/lib/raindown/list.rb +31 -0
  17. data/lib/raindown/markdown_nodes.rb +3 -2
  18. data/lib/raindown/node.rb +10 -2
  19. data/lib/raindown/node_blocks.rb +28 -27
  20. data/lib/raindown/node_injector.rb +7 -1
  21. data/lib/raindown/nodes.rb +61 -21
  22. data/lib/raindown/pages.rb +14 -8
  23. data/lib/raindown/pre_renderer.rb +13 -10
  24. data/lib/raindown/stat.rb +1 -1
  25. data/lib/raindown/table/markdown_table.rb +35 -0
  26. data/lib/raindown/table/table.rb +87 -0
  27. data/lib/raindown/toc.rb +2 -2
  28. data/lib/raindown/tui/cloud.rb +154 -0
  29. data/lib/raindown/tui/feelings/celebration/celebration_renderer.rb +22 -0
  30. data/lib/raindown/tui/feelings/feelings_renderer.rb +3 -16
  31. data/lib/raindown/tui/layout.rb +8 -1
  32. data/lib/raindown/tui/project.rb +23 -11
  33. data/lib/raindown/tui/renderers/endpoint_renderer.rb +4 -3
  34. data/lib/raindown/tui/terminal/loading.rb +34 -0
  35. data/lib/raindown/tui/terminal/screen.rb +6 -5
  36. data/lib/raindown/tui/terminal/styles.rb +13 -1
  37. data/lib/raindown/tui/views/endpoint_view.rb +5 -4
  38. data/lib/raindown/tui.rb +10 -6
  39. data/lib/raindown/wiki_links.rb +1 -1
  40. data/lib/raindown.rb +9 -14
  41. data/lib/version.rb +1 -1
  42. metadata +11 -5
  43. data/lib/lexemes/list_lexeme.rb +0 -32
  44. data/lib/nodes/component_nodes.rb +0 -38
  45. data/lib/raindown/files.rb +0 -22
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'node'
4
+
5
+ module Raindown
6
+ # The node behind list:, the files the metadata has for its props, like list: folder: 'notes'. Bare or in comments it
7
+ # writes a link to each, and in <{ list: page folder: 'notes' }> ... <{ :list }> it renders its template for each,
8
+ # with the file as page, the name before its props, which is its argument. A project can change how files are listed
9
+ # with its own List node in .raindown, like "class List < Raindown::List".
10
+ class List < Node
11
+ # Order is the metadata to sort by, like order: :created_at, rather than a tag to list by.
12
+ def initialize(_name = nil, order: nil, **tags)
13
+ @order = order
14
+ @tags = tags
15
+ end
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.
19
+ def files(metadata: NoMetadata)
20
+ files = metadata.list(**@tags)
21
+ return files unless @order
22
+
23
+ sorted, unsorted = files.partition { it[@order] }
24
+ sorted.sort_by.with_index { |file, index| [file[@order], index] } + unsorted
25
+ end
26
+
27
+ def rewrite(metadata: NoMetadata)
28
+ files(metadata:).map { |file| "- [#{file.title || file.path}](#{file.path})" }.join("\n")
29
+ end
30
+ end
31
+ end
@@ -57,10 +57,11 @@ module Raindown
57
57
 
58
58
  # Braces that the template pass hid, in its code or its props' values, are written back as braces.
59
59
  # The template pass loads here, as it loads nodes itself.
60
- def replace(metadata: NoMetadata, bare: false)
60
+ def rewrite(document: '', metadata: NoMetadata, bare: false)
61
61
  require_relative 'pre_renderer'
62
62
  defaults, template = FrontMatter.parse(File.read(self.class.path))
63
- markdown = PreRenderer.render(template, variables: Variables.new(defaults.merge(@props)), metadata:, bare:)
63
+ context = Context.new(document:, metadata:, bare:)
64
+ markdown = PreRenderer.render(template, variables: Variables.new(defaults.merge(@props)), context:)
64
65
  Variables.unwrap(markdown.sub(/\A(?:[ \t]*\n)+/, '').rstrip)
65
66
  end
66
67
  end
data/lib/raindown/node.rb CHANGED
@@ -1,7 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raindown
4
- # The base class of Raindown's own nodes, like TOC and Files.
4
+ # The base class of Raindown's own nodes, like TOC and List.
5
5
  # A project's nodes are LowNodes, or subclasses of these.
6
- class Node; end
6
+ class Node
7
+ # Write-in, in <{ }>, a node renders its Markdown as HTML. One whose rewrite takes written:, what it wrote last
8
+ # time, can write edits back to its source, like Table to its file, so it needs its own render, which never does.
9
+ def render(**context)
10
+ raise Nodes::NoRender, "#{self.class.name} writes edits back, so it needs a render" if Nodes.takes?(self, :rewrite, :written)
11
+
12
+ Raindown.commonmark(Nodes.call(self, :rewrite, **context).to_s)
13
+ end
14
+ end
7
15
  end
@@ -16,9 +16,10 @@ module Raindown
16
16
  # - **1.2.0** Bare directives
17
17
  #
18
18
  # <!-- :Changelog -->
19
- # In Antlers' delimiters, <{ Changelog: }> to <{ :Changelog }>, or a self-closing <{ :Changelog }> or bare
20
- # :Changelog, it renders the node without writing it. toc: and files: are Raindown's own nodes. A name only starts
21
- # a block when it's a loaded node, so other text like "Note:" is left alone.
19
+ # A bare :Changelog is write-in: it only renders, with its render, never its rewrite. In Antlers' delimiters, like
20
+ # <{ Changelog }>, it's write-in too, and the template pass renders it, as a WriteInNode, so it's only found here
21
+ # for a document's outline. toc: and list: are Raindown's own nodes. A name only starts a block when it's a loaded
22
+ # node, so other text like "Note:" is left alone.
22
23
  module NodeBlocks
23
24
  class MissingEndTag < StandardError; end
24
25
  class BrokenText < StandardError; end
@@ -27,7 +28,8 @@ module Raindown
27
28
  OPEN = /\A( {0,3})<\{\s*(#{NAME}):(?:\s+(.*?))?\s*\}>[ \t]*$/
28
29
  COMMENT_OPEN = /\A( {0,3})<!--\s*(#{NAME}):(?:\s+(.*?))?\s*-->[ \t]*$/
29
30
  BARE_OPEN = /\A( {0,3})(#{NAME}):(?:[ \t]+(.*?))?[ \t]*$/
30
- SELF_CLOSING = /\A( {0,3})<\{\s*:(#{NAME})(?:\s+(.*?))?\s*\}>[ \t]*$/
31
+ # <{ Card }>, or <{ :Card }>, which a lowercase name like toc needs, as <{ toc }> is a variable.
32
+ SELF_CLOSING = /\A( {0,3})<\{\s*(?::|(?=[A-Z]))(#{NAME})(?:\s+(.*?))?\s*\}>[ \t]*$/
31
33
  BARE_SELF_CLOSING = /\A( {0,3}):(#{NAME})(?:[ \t]+(.*?))?[ \t]*$/
32
34
  # Unquoted text after name:, like YAML's, runs up to the next prop, so title: Hello world limit: 3 has two.
33
35
  UNQUOTED = /.+?(?=[ \t]+\w+(?:=|:[ \t])|[ \t]*\z)/
@@ -40,12 +42,15 @@ module Raindown
40
42
  module_function
41
43
 
42
44
  # Each block outside code and front matter as { open:, close:, last:, writes:, node: }, where close is nil for a
43
- # bare block, and writes is whether its output's written into the file. A self-closing node is a block of one line
44
- # with self_closing: true.
45
+ # bare block, and writes is whether its output's written into the file. A self-closing node is a block of one line.
45
46
  def find(lines, front_matter: {}, bare: false)
47
+ with_nodes(spans(lines, bare:), front_matter)
48
+ end
49
+
50
+ def with_nodes(spans, front_matter)
46
51
  variables = Variables.new(front_matter)
47
52
 
48
- spans(lines, bare:).map do |span|
53
+ spans.map do |span|
49
54
  node = Nodes.instance(span[:class], *args(span[:args], variables, antlers: span[:antlers]))
50
55
  span.except(:name, :class, :args, :antlers).merge(node:)
51
56
  end
@@ -71,28 +76,29 @@ module Raindown
71
76
  spans
72
77
  end
73
78
 
74
- # Swaps each block for its node's HTML, through a placeholder, or for its Markdown when it has no HTML of its own.
75
- # The directive and end tag become Directives markers, which also keep the node's output from joining
76
- # the Markdown around it, like two lists becoming one.
77
- def to_rendered(markdown, metadata: NoMetadata, bare: false)
78
- lines = markdown.lines
79
- find(lines, front_matter: FrontMatter.parse(markdown).first, bare:).reverse_each do |block|
80
- lines[block[:open]..block[:last]] = rendered_lines(lines, block, markdown:, metadata:, bare:)
79
+ # Swaps each bare block or block in comments for its node's Markdown, or its HTML when it has its own, or only renders,
80
+ # as a fragment or a placeholder. The directive and end tag become Directives markers, which also keep the node's
81
+ # output from joining the Markdown around it, like two lists becoming one. Ones in <{ }> are left to Antlers.
82
+ def to_rendered(context)
83
+ lines = context.document.dup.force_encoding('UTF-8').lines
84
+ spans = spans(lines, bare: context.bare).reject { it[:antlers] }
85
+ with_nodes(spans, FrontMatter.parse(context.document).first).reverse_each do |block|
86
+ lines[block[:open]..block[:last]] = rendered_lines(lines, block, context)
81
87
  end
82
88
  lines.join
83
89
  end
84
90
 
85
- def rendered_lines(lines, block, markdown:, metadata:, bare:)
86
- output = node_markdown(block[:node], markdown:, metadata:, bare:).chomp
91
+ def rendered_lines(lines, block, context)
92
+ output = node_markdown(block, context).chomp
87
93
  close = marker(lines[block[:close]]) if block[:close]
88
94
 
89
95
  [marker(lines[block[:open]]), *("#{output}\n".lines unless output.empty?), *close]
90
96
  end
91
97
 
92
- def node_markdown(node, markdown:, metadata:, bare:)
93
- return Nodes.placeholder(node) if Nodes.html?(node)
98
+ def node_markdown(block, context)
99
+ return Nodes.rendered(block[:node], context) if !block[:writes] || Nodes.html?(block[:node])
94
100
 
95
- Nodes.call(node, :replace, document: markdown, metadata:, bare:).to_s
101
+ Nodes.call(block[:node], :rewrite, **context.to_h).to_s
96
102
  end
97
103
 
98
104
  def marker(line)
@@ -109,7 +115,7 @@ module Raindown
109
115
  end_tag(lines, index, found[2], comment: pattern == :comment_open)
110
116
  .then { |close| { open: index, close:, last: close, writes: pattern == :comment_open, **span } }
111
117
  when :bare_open then { **bare_block(lines, index), writes: true, **span }
112
- else { open: index, close: nil, last: index, writes: false, self_closing: true, **span }
118
+ else { open: index, close: nil, last: index, writes: false, **span }
113
119
  end
114
120
  end
115
121
 
@@ -127,12 +133,7 @@ module Raindown
127
133
  end
128
134
 
129
135
  # A bare block holds the lines straight below it, up to the next blank line, with fenced code kept whole.
130
- def bare_block(lines, open)
131
- last = open
132
- last = CodeBlocks.at(lines, last + 1)&.fetch(:close) || (last + 1) while lines[last + 1] && !lines[last + 1].strip.empty?
133
-
134
- { open:, close: nil, last: }
135
- end
136
+ def bare_block(lines, open) = { open:, close: nil, last: CodeBlocks.last_before_blank(lines, open) }
136
137
 
137
138
  # The end tag written the same way as the directive: in a comment, or in Antlers' delimiters.
138
139
  def end_tag(lines, open, name, comment:)
@@ -161,7 +162,7 @@ module Raindown
161
162
  end
162
163
 
163
164
  # A value, like 'text', 3, true, nil or :draft, or else a variable. In Antlers' delimiters a word is a variable, like
164
- # author.name, while bare or in a comment, which read like Markdown, it's text, like notes in files: folder: notes,
165
+ # author.name, while bare or in a comment, which read like Markdown, it's text, like notes in list: folder: notes,
165
166
  # and only one in braces is a variable, like {author.name}.
166
167
  def value(value, variables, antlers: true)
167
168
  Lowkey::ValueParser.parse(value) do
@@ -35,7 +35,8 @@ module Raindown
35
35
  private
36
36
 
37
37
  def block_lines(lines, block)
38
- markdown = Nodes.call(block[:node], :replace, document: @content, metadata: @metadata, bare: @bare).to_s.chomp
38
+ context = { document: @content, metadata: @metadata, bare: @bare, written: written_lines(lines, block) }
39
+ markdown = Nodes.call(block[:node], :rewrite, **context).to_s.chomp
39
40
  written = markdown.empty? ? [] : "#{markdown}\n".lines
40
41
  return [line(lines[block[:open]]), *bare(written, block)] unless block[:close]
41
42
 
@@ -54,6 +55,11 @@ module Raindown
54
55
  raise BlankLine, "#{name} writes blank lines, so it needs <!-- #{name}: --> and <!-- :#{name} --> around it"
55
56
  end
56
57
 
58
+ # What the block's node wrote last time, between its directive and its end tag, or the next blank line.
59
+ def written_lines(lines, block)
60
+ lines[(block[:open] + 1)...(block[:close] || (block[:last] + 1))]
61
+ end
62
+
57
63
  def line(line)
58
64
  line.end_with?("\n") ? line : "#{line}\n"
59
65
  end
@@ -1,25 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'chart/chart'
4
- require_relative 'files'
4
+ require_relative 'fragments'
5
5
  require_relative 'js/js_nodes'
6
+ require_relative 'list'
6
7
  require_relative 'markdown_nodes'
7
8
  require_relative 'node'
8
9
  require_relative 'stat'
10
+ require_relative 'table/table'
9
11
  require_relative 'toc'
10
12
 
11
13
  module Raindown
12
14
  # Nodes are LowNodes that a node block writes into the Markdown and renders into the HTML.
13
15
  # A project keeps them in a .raindown folder, which is loaded with Dirload, beside its JavaScript and Markdown
14
16
  # Components, if any.
15
- # A node writes Markdown with #replace, and HTML with #render if it has one. Either can take the keywords
16
- # document: (the Markdown being rendered), metadata: and bare:, and gets the ones it asks for.
17
+ # Bare or in comments, a node writes back: its Markdown, from #rewrite, is written into the file, and its HTML is from
18
+ # #render if it has one. In <{ }> it's write-in: it only renders, with #render, which takes the Markdown between its
19
+ # tags as slot:, in HTML. Either can take the keywords document: (the Markdown being rendered), metadata:, bare: and
20
+ # written: (the lines it wrote last time), and gets the ones it asks for.
17
21
  module Nodes
22
+ # A write-in node that has no render.
23
+ class NoRender < StandardError; end
24
+
18
25
  FOLDER = '.raindown'
19
- # Lowercase directives for Raindown's own nodes. A project's own TOC, Files or Chart node replaces Raindown's.
20
- BUILT_INS = { 'toc' => 'TOC', 'files' => 'Files', 'chart' => 'Chart' }.freeze
21
- # A placeholder for a node's HTML, which stays as it is through Commonmarker and Antlers.
22
- PLACEHOLDER = /<!-- raindown:node (\d+) -->/
26
+ # Lowercase directives for Raindown's own nodes. A project's own TOC, List, Chart or Table node replaces Raindown's.
27
+ BUILT_INS = { 'toc' => 'TOC', 'list' => 'List', 'chart' => 'Chart', 'table' => 'Table' }.freeze
28
+ # A placeholder for a node's HTML, which stays as it is through Commonmarker and Antlers, with its slot's HTML, as a
29
+ # fragment, when it has one.
30
+ PLACEHOLDER = /<!-- raindown:node (\d+)(?: slot (\d+))? -->/
23
31
 
24
32
  module_function
25
33
 
@@ -61,21 +69,21 @@ module Raindown
61
69
 
62
70
  # A Ruby node of the name, or else a JavaScript one, or else a Markdown one.
63
71
  def loaded_class(name)
64
- klass = Object.const_get(name) if Object.const_defined?(name)
72
+ klass = Object.const_get(name) if name.match?(/\A[A-Z]/) && Object.const_defined?(name)
65
73
  return klass if node?(klass)
66
74
 
67
75
  JSNodes.node_class(name) || MarkdownNodes.node_class(name)
68
76
  end
69
77
 
70
78
  # A LowNode, or one of Raindown's own nodes, that writes Markdown. In an app, LowNodes are also routes and pages,
71
- # which don't have replace, so text like "Note:" only starts a block when the Note node writes something.
79
+ # which don't have rewrite, so text like "Note:" only starts a block when the Note node writes something.
72
80
  def node?(klass)
73
- return false unless klass.is_a?(Class) && klass.method_defined?(:replace)
81
+ return false unless klass.is_a?(Class) && klass.method_defined?(:rewrite)
74
82
 
75
83
  klass < Node || (defined?(::LowNode) && klass < ::LowNode) || false
76
84
  end
77
85
 
78
- # Calls a node's replace or render with the context keywords it takes.
86
+ # Calls a node's rewrite or render with the context keywords it takes.
79
87
  def call(node, name, **context)
80
88
  parameters = node.method(name).parameters
81
89
  return node.public_send(name, **context) if parameters.any? { |type, _| type == :keyrest }
@@ -97,24 +105,56 @@ module Raindown
97
105
  list[instances[key]]
98
106
  end
99
107
 
100
- def placeholder(node)
101
- "<!-- raindown:node #{list.index(node)} -->"
108
+ # Renders the node where it is, as a fragment, so its headings are part of the page, or through a placeholder, once
109
+ # the page is HTML, when its render takes html:, the page's HTML, like TOC's.
110
+ def rendered(node, context, slot: '')
111
+ raise NoRender, "#{node.class.name} has no render, so it can't be write-in: write it bare or in comments" unless renders?(node)
112
+ return placeholder(node, slot:) if !template?(node.class) && takes?(node, :render, :html)
113
+
114
+ Fragments.add(html(node, context, slot:))
115
+ end
116
+
117
+ def takes?(node, method_name, keyword)
118
+ node.method(method_name).parameters.any? { |_, name| name == keyword }
102
119
  end
103
120
 
104
- # Swaps each placeholder for its node's HTML.
105
- def render_placeholders(html, **context)
106
- html.gsub(PLACEHOLDER) { html(list[::Regexp.last_match(1).to_i], **context) }
121
+ # Whether the node's class has a template, like a Raindeer .rbx component, which renders in place of render.
122
+ def template?(klass)
123
+ klass.respond_to?(:template) && !klass.template.nil?
107
124
  end
108
125
 
109
- def html(node, **context)
110
- return node.render_template(props: {}).to_s if node.class.respond_to?(:template) && node.class.template
126
+ def placeholder(node, slot: '')
127
+ return "<!-- raindown:node #{list.index(node)} -->" if slot.empty?
111
128
 
112
- call(node, :render, **context).to_s
129
+ "<!-- raindown:node #{list.index(node)} slot #{Fragments.store(slot)} -->"
130
+ end
131
+
132
+ # Swaps each placeholder for its node's HTML, given the page's HTML and its slot, the HTML between its tags in <{ }>,
133
+ # which can hold components' fragments.
134
+ def render_placeholders(page_html, context)
135
+ page_html.gsub(PLACEHOLDER) do
136
+ match = ::Regexp.last_match
137
+ slot = match[2] ? Fragments.list[match[2].to_i] : ''
138
+ Fragments.fill(html(list[match[1].to_i], context, slot:, html: page_html))
139
+ end
113
140
  end
114
141
 
115
- # Whether the node renders its own HTML rather than the HTML of its Markdown.
142
+ def html(node, context, **keywords)
143
+ return node.render_template(props: {}).to_s if template?(node.class)
144
+
145
+ call(node, :render, **context.to_h, **keywords).to_s
146
+ end
147
+
148
+ # Whether the node renders its own HTML rather than the HTML of its Markdown, which Raindown's nodes render by default.
116
149
  def html?(node)
117
- node.respond_to?(:render) || (node.class.respond_to?(:template) && !node.class.template.nil?)
150
+ return true if template?(node.class)
151
+
152
+ node.respond_to?(:render) && node.method(:render).owner != Node
153
+ end
154
+
155
+ # Whether the node renders at all, so it can be write-in, in <{ }>.
156
+ def renders?(node)
157
+ node.respond_to?(:render) || html?(node)
118
158
  end
119
159
 
120
160
  def instances
@@ -5,7 +5,7 @@ require_relative 'page'
5
5
  require_relative 'utils'
6
6
 
7
7
  module Raindown
8
- # The metadata of a project's Markdown files, which list: and files: list by tag.
8
+ # The metadata of a project's Markdown files, which list: lists by tag.
9
9
  # Ported from Raindeer's Rain::Pages, and takes the same Dirload metadata.
10
10
  #
11
11
  # Each file is tagged with its front matter, like "tags: [ruby, docs]" or "date: 2099", and by its path,
@@ -61,10 +61,12 @@ module Raindown
61
61
  attr_reader :root
62
62
 
63
63
  # Render options, like highlight_code: false, are for rendering each listed file's content.
64
- def initialize(metadata:, root:, render_options: {})
64
+ # Eager reads every file's front matter straight away, for an app like Raindeer that needs all of it from the start.
65
+ def initialize(root:, metadata:, render_options: {}, eager: false)
65
66
  @root = root
66
67
  @render_options = render_options
67
- @file_paths = metadata.file_types.values_at(*EXTENSIONS).flatten.compact
68
+ @file_paths = filtered_paths(metadata:)
69
+ load_metadata if eager
68
70
  end
69
71
 
70
72
  # The files with any of the tags, like list(folder: 'basics') or list(tags: 'ruby'), sorted by order.
@@ -80,17 +82,22 @@ module Raindown
80
82
 
81
83
  private
82
84
 
85
+ # The Markdown files in Dirload's metadata, only those under the root path.
86
+ def filtered_paths(metadata:)
87
+ metadata.file_types.values_at(*EXTENSIONS).flatten.compact.select { it.start_with?("#{@root}/") }
88
+ end
89
+
83
90
  def metadata
84
- process_files unless @metadata
91
+ load_metadata unless @metadata
85
92
  @metadata
86
93
  end
87
94
 
88
95
  def tags
89
- process_files unless @tags
96
+ load_metadata unless @tags
90
97
  @tags
91
98
  end
92
99
 
93
- def process_files
100
+ def load_metadata
94
101
  @metadata = {}
95
102
  @tags = {}
96
103
  @file_paths.each { |file_path| process(file_path:) }
@@ -101,8 +108,7 @@ module Raindown
101
108
  end
102
109
 
103
110
  def process(file_path:)
104
- metadata = implicit_metadata(file_path:).merge(front_matter(file_path))
105
- @metadata[file_path] = metadata
111
+ metadata = @metadata[file_path] = implicit_metadata(file_path:).merge(front_matter(file_path))
106
112
  metadata.each { |type, tag| Array(tag).each { |item| tag(type:, tag: item, file_path:) } }
107
113
  end
108
114
 
@@ -5,31 +5,33 @@ require_relative '../elements'
5
5
  require_relative 'bare_directives'
6
6
  require_relative 'code'
7
7
  require_relative 'code_blocks'
8
+ require_relative 'context'
8
9
  require_relative 'front_matter'
9
10
 
10
11
  module Raindown
11
12
  # Renders the directives whose output is Markdown, before Commonmarker reads it: variables, loops, conditions and lists.
12
13
  # So a loop's items become one Markdown list or table, and a heading's id and its entry in a table of contents
13
14
  # come from its text, like "# Notes for {name}" becoming "Notes for Mark" with the id "notes-for-mark".
14
- # Nodes like toc: and files: render before this. Components and endpoints render here too, as fragments of HTML
15
- # that Raindown puts back once the document is HTML.
15
+ # Nodes bare or in comments, like toc: and list:, render before this. Nodes in <{ }> and endpoints render here, as
16
+ # fragments of HTML that Raindown puts back once the document is HTML.
16
17
  module PreRenderer
17
18
  BLOCK_DIRECTIVES = %w[for if list].freeze
18
19
  # A for, if or list directive alone on its line, which leaves no line behind, so a loop's list items stay together.
19
- BLOCK_LINE = /^[ \t]*(<\{\s*:?(?:#{BLOCK_DIRECTIVES.join('|')})\b[^}]*\}>)[ \t]*\n/
20
+ # A self-closing <{ :list folder: 'notes' }> isn't one, as only an open tag has props after its name.
21
+ BLOCK_LINE = /^[ \t]*(<\{\s*(?:(?:#{BLOCK_DIRECTIVES.join('|')}):[^}]*|:(?:#{BLOCK_DIRECTIVES.join('|')})\s*)\}>)[ \t]*\n/
20
22
  # An endpoint's directive or end tag in a comment, which renders as it would in Antlers' delimiters.
21
23
  ENDPOINT_COMMENT = /\A( {0,3})<!--\s*(endpoint:.*?|:endpoint)\s*-->/
22
24
 
23
25
  module_function
24
26
 
25
- def render(markdown, variables:, metadata: {}, bare: false)
26
- markdown = BareDirectives.expand(markdown) if bare
27
+ def render(markdown, variables:, context: Context.new)
28
+ markdown = BareDirectives.expand(markdown) if context.bare
27
29
  markdown = delimited_endpoints(markdown) if markdown.include?('<!--')
28
30
  return markdown unless markdown.include?('{')
29
31
 
30
32
  markdown = Code.hide_braces(markdown.dup.force_encoding('UTF-8'))
31
33
  body = FrontMatter.parse(markdown).last
32
- "#{markdown.delete_suffix(body)}#{render_body(body, variables, metadata)}"
34
+ "#{markdown.delete_suffix(body)}#{render_body(body, variables, context)}"
33
35
  end
34
36
 
35
37
  # The endpoints in comments in Antlers' delimiters, which the template pass renders, leaving code as it is.
@@ -46,16 +48,17 @@ module Raindown
46
48
  lines.join
47
49
  end
48
50
 
49
- def render_body(body, variables, metadata)
51
+ def render_body(body, variables, context)
50
52
  template = body.gsub(BLOCK_LINE, '\1')
51
53
  return body unless template.include?('{')
52
54
 
53
55
  # Antlers strips the whitespace around a template, which can be Markdown, like an indented code block.
54
56
  leading = template[/\A\s*/]
55
57
  trailing = template[/\s*\z/]
56
- # Components and endpoints render here too, where loop variables are in scope, and keep their HTML as fragments.
57
- elements = Antlers::Elements[:html, :var, :if, :for] + Elements[:list, :endpoint, :prop_component, :slot_component]
58
- rendered = Antlers.render(ast: Antlers.ast(template:, elements:), current_binding: variables.binding, metadata:)
58
+ # Write-in nodes and endpoints render here too, where loop variables are in scope, and keep their HTML as
59
+ # fragments.
60
+ elements = Antlers::Elements[:html, :var, :if, :for] + Elements[:write_in, :endpoint]
61
+ rendered = Antlers.render(ast: Antlers.ast(template:, elements:), current_binding: variables.binding, context:)
59
62
 
60
63
  "#{leading}#{rendered}#{trailing}"
61
64
  end
data/lib/raindown/stat.rb CHANGED
@@ -23,7 +23,7 @@ module Raindown
23
23
  '9' => ['█▀█', '▀▀█', '▀▀▀'], '-' => [' ', '▀▀▀', ' '], '+' => [' ▄ ', '▀█▀', ' '],
24
24
  '.' => [' ', ' ', '▀'], ',' => [' ', ' ', '▀'] }.freeze
25
25
 
26
- def replace(metadata: NoMetadata) = "```\n#{card_lines(metadata:).join("\n")}\n```"
26
+ def rewrite(metadata: NoMetadata) = "```\n#{card_lines(metadata:).join("\n")}\n```"
27
27
 
28
28
  # The number in block digits, then the label, as a card on the Dashboard shows them.
29
29
  def card_lines(metadata: NoMetadata)
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
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.
6
+ module MarkdownTable
7
+ SEPARATOR = /\A\|?(\s*:?-+:?\s*\|)*\s*:?-+:?\s*\|?\z/
8
+ # A | that isn't escaped.
9
+ PIPE = /(?<!\\)\|/
10
+
11
+ module_function
12
+
13
+ def format(rows)
14
+ return '' if rows.empty?
15
+
16
+ header, *body = rows.map { |row| "| #{row.map { escape(it) }.join(' | ')} |" }
17
+ [header, "|#{Array.new(rows.first.length, '---').join('|')}|", *body].join("\n")
18
+ end
19
+
20
+ # The rows of the table in the lines, leaving out its separator, or none when they aren't a table.
21
+ def parse(lines)
22
+ return [] unless lines[1]&.strip&.match?(SEPARATOR)
23
+
24
+ [lines[0], *lines.drop(2)].take_while { it.strip.start_with?('|') }.map { cells(it.strip) }
25
+ end
26
+
27
+ def cells(line)
28
+ line.delete_prefix('|').delete_suffix('|').split(PIPE, -1).map { it.strip.gsub('\|', '|') }
29
+ end
30
+
31
+ def escape(cell)
32
+ cell.gsub('|', '\|').gsub(/\s*\R\s*/, ' ')
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'csv'
4
+ require 'digest'
5
+ require 'json'
6
+
7
+ require_relative '../node'
8
+ require_relative 'markdown_table'
9
+
10
+ module Raindown
11
+ # The node behind table:, a CSV or TSV file relative to the project as a Markdown table, after a comment of how many
12
+ # rows it has and a hash of them, as they were when the table and the file last matched:
13
+ # table: 'data/team.csv'
14
+ # <!-- table 2 rows · 3f2a9c -->
15
+ # | name | role |
16
+ # |------|----------|
17
+ # | Ada | engineer |
18
+ # | Lin | design |
19
+ # Edit the table and its rows are written back to the file, or edit the file and the table's written again. When
20
+ # both changed, neither is, and the table's comment says so until they match again.
21
+ class Table < Node
22
+ COMMENT = /\A<!-- table (?:conflict|\d+ rows?) · (\h+) -->\z/
23
+ SEPARATORS = { '.csv' => ',', '.tsv' => "\t" }.freeze
24
+
25
+ def initialize(data)
26
+ @data = data
27
+ end
28
+
29
+ # Written is what it wrote last time, so it can tell which of the table and the file changed since.
30
+ def rewrite(written: [], metadata: NoMetadata)
31
+ path = data_path(metadata)
32
+ data_error(path) || synced(path, written.map(&:chomp))
33
+ end
34
+
35
+ # Write-in, in <{ }>, the table only shows its file, and never writes to it.
36
+ def render(metadata: NoMetadata)
37
+ path = data_path(metadata)
38
+ Raindown.commonmark(data_error(path) || MarkdownTable.format(read_rows(path)))
39
+ end
40
+
41
+ private
42
+
43
+ def data_path(metadata)
44
+ File.expand_path(@data.to_s, metadata.respond_to?(:root) ? metadata.root : Dir.pwd)
45
+ end
46
+
47
+ # Why the file can't be a table, or nil when it can.
48
+ def data_error(path)
49
+ return "Can't show #{@data.inspect} as a table: only CSV and TSV files can be" unless SEPARATORS.key?(File.extname(path).downcase)
50
+
51
+ "Can't show #{@data.inspect} as a table: no such file" unless File.file?(path)
52
+ end
53
+
54
+ def synced(path, written)
55
+ file = read_rows(path)
56
+ synced_hash = written.first&.[](COMMENT, 1)
57
+ table = MarkdownTable.parse(written.drop(1))
58
+ return markdown(file) if synced_hash.nil? || table.empty? || [synced_hash, hash_rows(file)].include?(hash_rows(table))
59
+ return markdown(write_rows(path, table)) if hash_rows(file) == synced_hash
60
+
61
+ warn "raindown: #{@data} and its table both changed, so neither is written until they match"
62
+ ["<!-- table conflict · #{synced_hash} -->", *written.drop(1)].join("\n")
63
+ end
64
+
65
+ def markdown(rows)
66
+ return '' if rows.empty?
67
+
68
+ count = rows.length - 1
69
+ "<!-- table #{count} row#{'s' unless count == 1} · #{hash_rows(rows)} -->\n#{MarkdownTable.format(rows)}"
70
+ end
71
+
72
+ def read_rows(path)
73
+ rows = CSV.read(path, col_sep: SEPARATORS[File.extname(path).downcase], skip_blanks: true, encoding: Encoding::UTF_8)
74
+ width = rows.map(&:length).max.to_i
75
+ rows.map { |row| Array.new(width) { row[it].to_s.strip } }
76
+ end
77
+
78
+ def write_rows(path, rows)
79
+ File.write(path, rows.map { CSV.generate_line(it, col_sep: SEPARATORS[File.extname(path).downcase]) }.join)
80
+ rows
81
+ end
82
+
83
+ def hash_rows(rows)
84
+ Digest::SHA256.hexdigest(JSON.generate(rows))[0, 6]
85
+ end
86
+ end
87
+ end
data/lib/raindown/toc.rb CHANGED
@@ -14,7 +14,7 @@ module Raindown
14
14
  IGNORED = %r{<!--.*?-->|<script\b.*?</script\s*>}mi
15
15
  TAG = /<[^>]*>/
16
16
 
17
- def replace(document:, metadata: nil, bare: false)
17
+ def rewrite(document:, metadata: nil, bare: false)
18
18
  headings = headings(markdown: document, metadata:, bare:)
19
19
  return '' if headings.empty?
20
20
 
@@ -45,7 +45,7 @@ module Raindown
45
45
 
46
46
  # The document's headings, from its final HTML, or else by rendering it with its components.
47
47
  def headings(markdown:, html: nil, metadata: nil, bare: false)
48
- html ||= Fragments.collect { Raindown.assembled(markdown:, metadata: metadata || NoMetadata, bare:) }
48
+ html ||= Fragments.collect { Raindown.assembled(Context.new(document: markdown, metadata: metadata || NoMetadata, bare:)) }
49
49
 
50
50
  html.gsub(IGNORED, '').scan(HEADING).map do |level, *ids, content|
51
51
  { level: level.to_i, text: CGI.unescapeHTML(content.gsub(TAG, '')).strip, id: CGI.unescapeHTML(ids.compact.first) }