raindown 0.2.0 → 0.2.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: ab2295aa7f9468fa67aaf9d8192de859220c37cec922ef558607730a0544a86a
4
- data.tar.gz: 0ede0a653f452c0a9f12f9a73c35dce3df85a12e9ff35350d741ae9c7c0e3522
3
+ metadata.gz: d0e77b47d4bdedcda5ace520aff8dabc470b3d49a9d7f2dac83b43d23d0928c2
4
+ data.tar.gz: 0e26371c232241abfcfbaec62a09eb89445299fdd5e4d67273a9c0839bb1982a
5
5
  SHA512:
6
- metadata.gz: f72d0180ff9b4b310c25efa2872e59dc5d31da9dacff67d7d047737cab25e3b6647b2d74fb2a130c9354f0c434cf3a57e5a7c0750a54d762a47121901abcbb83
7
- data.tar.gz: 3bcc85d7eb3b2055f374028f2bdcc3e561530ea36739e655d96c0b26f5038d7e83104b7201ee9b2654ecd8ea6e550c96d9733412bb2ff07a7f31f5858630c003
6
+ metadata.gz: 917089b01769a65e0b96d645613f182fdbd7212760823293ed87dd1581071751f53548bdc84d363c40e5a50d4c4941c10f6a409ded7dd2c5687708581dcd9213
7
+ data.tar.gz: 1ae3a21d68f7bb6fd7042e371cec3352eb7e1781855dcf3a5c92b80c1024a628481c2a3f159be9ee6fb1769592169640f5d767989aadf29bfbadba76ab610849
data/lib/elements.rb CHANGED
@@ -6,6 +6,31 @@ module Raindown
6
6
  class Elements
7
7
  include Plugs
8
8
 
9
+ # Antlers' prop and slot directives, rendered as components in the template pass.
10
+ plug(:prop_component) do
11
+ plug(:lexeme) do
12
+ require 'lexemes/prop_lexeme'
13
+ Antlers::PropLexeme
14
+ end
15
+
16
+ plug(:node) do
17
+ require_relative 'nodes/component_nodes'
18
+ PropComponentNode
19
+ end
20
+ end
21
+
22
+ plug(:slot_component) do
23
+ plug(:lexeme) do
24
+ require 'lexemes/slot_lexeme'
25
+ Antlers::SlotLexeme
26
+ end
27
+
28
+ plug(:node) do
29
+ require_relative 'nodes/component_nodes'
30
+ SlotComponentNode
31
+ end
32
+ end
33
+
9
34
  plug(:endpoint) do
10
35
  plug(:lexeme) do
11
36
  require_relative 'lexemes/endpoint_lexeme'
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'antlers/api'
4
+ require 'nodes/html_node'
5
+ require 'nodes/prop_node'
6
+ require 'nodes/slot_node'
7
+ require_relative '../raindown/fragments'
8
+
9
+ module Raindown
10
+ # <{ Card name=p.name }>, rendered by Antlers where it is in the template, so its props see loop variables.
11
+ # Its HTML is kept as a fragment, so Commonmarker doesn't read it as Markdown.
12
+ class PropComponentNode < Antlers::PropNode
13
+ def render(**)
14
+ Fragments.add(super)
15
+ end
16
+ end
17
+
18
+ # <{ Card: }>...<{ :Card }>, whose slot is its own Markdown, rendered to HTML in its scope first.
19
+ #
20
+ # Markdown children -> rendered to a Markdown string -> Commonmarker -> HTML
21
+ # -> wrapped as a single Antlers::HTMLNode (replaces @children for the super call)
22
+ # -> Antlers::SlotNode#render's "without a template" branch -> slot: receives that HTML
23
+ # -> Card#render(slot:, **props)
24
+ #
25
+ # This only reaches Card#render directly when Card has no class-level template (see SlotNode#render);
26
+ # otherwise Antlers renders Card's own template instead, with this HTML passed to it as the slot_node.
27
+ class SlotComponentNode < Antlers::SlotNode
28
+ def render(current_binding: nil, parent_binding: nil, slot_node: nil, metadata: {})
29
+ markdown = @children.map { |child| child.render(current_binding:, parent_binding:, slot_node:, metadata:) || '' }.join
30
+ children = @children
31
+ @children = [Antlers::HTMLNode.new(html: Raindown.commonmark(markdown))]
32
+
33
+ Fragments.add(super)
34
+ ensure
35
+ @children = children if children
36
+ end
37
+ end
38
+ end
@@ -2,10 +2,11 @@
2
2
 
3
3
  require 'antlers/api'
4
4
  require_relative '../raindown/directives'
5
+ require_relative '../raindown/fragments'
5
6
 
6
7
  module Raindown
7
8
  # Holds an endpoint's curl snippet and response, which render as the code blocks they are,
8
- # between Directives markers for its directive and end tag.
9
+ # between Directives markers for its directive and end tag, kept as a fragment.
9
10
  class EndpointNode < Antlers::BranchNode
10
11
  DEF_KEY = :endpoint_def
11
12
  END_KEY = :endpoint_end
@@ -17,7 +18,8 @@ module Raindown
17
18
  end
18
19
 
19
20
  def render(**)
20
- "#{Directives.marker(@directive)}\n#{super.sub(/\A\n+/, '')}#{Directives.marker(':endpoint')}"
21
+ html = Raindown.commonmark(super)
22
+ Fragments.add("#{Directives.marker(@directive)}\n#{html}#{Directives.marker(':endpoint')}")
21
23
  end
22
24
 
23
25
  class << self
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ # The HTML of components rendered while a document renders, kept out of its Markdown as placeholders
5
+ # so Commonmarker doesn't read it as Markdown, then filled in once the document is HTML.
6
+ module Fragments
7
+ PLACEHOLDER = /<!-- raindown:fragment (\d+) -->/
8
+
9
+ module_function
10
+
11
+ # Collects the fragments added while the block runs, like one document's render.
12
+ def collect
13
+ outer = Thread.current[:raindown_fragments]
14
+ Thread.current[:raindown_fragments] = []
15
+ yield
16
+ ensure
17
+ Thread.current[:raindown_fragments] = outer
18
+ end
19
+
20
+ def add(html)
21
+ list << html
22
+ "<!-- raindown:fragment #{list.length - 1} -->"
23
+ end
24
+
25
+ # Fills each placeholder, again and again, since a fragment can hold others, like a component in a slot.
26
+ def fill(html)
27
+ html = html.gsub(PLACEHOLDER) { list[::Regexp.last_match(1).to_i] } while html.match?(PLACEHOLDER)
28
+ html
29
+ end
30
+
31
+ def list
32
+ Thread.current[:raindown_fragments] ||= []
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ # Commonmarker gives each heading a unique id within what it renders, but a document's components render
5
+ # on their own, so two can have a heading with the same id. Later ones get -1, -2 and so on, like Commonmarker.
6
+ module HeadingIds
7
+ HEADING = %r{<h([1-6]) id="([^"]*)"(.*?)</h\1>}m
8
+
9
+ module_function
10
+
11
+ def unique(html)
12
+ seen = Hash.new(0)
13
+
14
+ html.gsub(HEADING) do
15
+ level, id, rest = ::Regexp.last_match.captures
16
+ count = seen[id]
17
+ seen[id] += 1
18
+ next ::Regexp.last_match(0) if count.zero?
19
+
20
+ unique_id = "#{id}-#{count}"
21
+ "<h#{level} id=\"#{unique_id}\"#{rest.gsub("href=\"##{id}\"", "href=\"##{unique_id}\"")}</h#{level}>"
22
+ end
23
+ end
24
+ end
25
+ end
data/lib/raindown/page.rb CHANGED
@@ -6,16 +6,21 @@ module Raindown
6
6
  class Page
7
7
  attr_reader :file_path, :metadata
8
8
 
9
- def initialize(file_path:, metadata:)
9
+ def initialize(file_path:, metadata:, render_options: {})
10
10
  @file_path = file_path
11
11
  @metadata = metadata
12
+ @render_options = render_options
12
13
  end
13
14
 
14
15
  # Rendered when asked for, so a file that lists itself doesn't render forever.
16
+ # The front matter is rendered with it, so its variables work, and a Raindown file's bare directives too.
15
17
  def content
16
18
  @content ||= begin
17
- markdown = FrontMatter.parse(File.read(file_path)).last.strip
18
- markdown.empty? ? '' : Raindown.render(markdown:)
19
+ markdown = File.read(file_path, encoding: Encoding::UTF_8)
20
+
21
+ if FrontMatter.parse(markdown).last.strip.empty? then ''
22
+ else Raindown.render(markdown:, bare: BareDirectives.file?(file_path), **@render_options)
23
+ end
19
24
  end
20
25
  end
21
26
 
@@ -31,8 +31,10 @@ module Raindown
31
31
  end
32
32
  end
33
33
 
34
- def initialize(metadata:, root:)
34
+ # Render options, like hide_directives: true, are for rendering each listed file's content.
35
+ def initialize(metadata:, root:, render_options: {})
35
36
  @root = root
37
+ @render_options = render_options
36
38
  @file_paths = metadata.file_types.values_at(*EXTENSIONS).flatten.compact
37
39
  @metadata = {}
38
40
  @tags = {}
@@ -44,7 +46,7 @@ module Raindown
44
46
  # Without tags, every file.
45
47
  def list(**tags)
46
48
  file_paths = tags.empty? ? @file_paths : tagged(**tags)
47
- file_paths.sort_by { |file_path| sort_key(file_path) }.map { |file_path| Page.new(file_path:, metadata: @metadata[file_path]) }
49
+ file_paths.sort_by { |file_path| sort_key(file_path) }.map { |file_path| listed_page(file_path) }
48
50
  end
49
51
 
50
52
  def tagged(**tags)
@@ -53,8 +55,12 @@ module Raindown
53
55
 
54
56
  private
55
57
 
58
+ def listed_page(file_path)
59
+ Page.new(file_path:, metadata: @metadata[file_path], render_options: @render_options)
60
+ end
61
+
56
62
  def process(file_path:)
57
- metadata = implicit_metadata(file_path:).merge(FrontMatter.parse(File.read(file_path)).first)
63
+ metadata = implicit_metadata(file_path:).merge(FrontMatter.parse(File.read(file_path, encoding: Encoding::UTF_8)).first)
58
64
  @metadata[file_path] = metadata
59
65
  metadata.each { |type, tag| Array(tag).each { |item| tag(type:, tag: item, file_path:) } }
60
66
  end
@@ -10,14 +10,12 @@ module Raindown
10
10
  # Renders the directives whose output is Markdown, before Commonmarker reads it: variables, loops, conditions and lists.
11
11
  # So a loop's items become one Markdown list or table, and a heading's id and its entry in a table of contents
12
12
  # come from its text, like "# Notes for {name}" becoming "Notes for Mark" with the id "notes-for-mark".
13
- # Nodes like toc: and files: render before this, and directives whose output is HTML render in the HTML afterwards.
13
+ # Nodes like toc: and files: render before this. Components and endpoints render here too, as fragments of HTML
14
+ # that Raindown puts back once the document is HTML.
14
15
  module PreRenderer
15
16
  BLOCK_DIRECTIVES = %w[for if list].freeze
16
- # A variable like <{ title }> or a for, if or list directive, which render here.
17
- DIRECTIVE = /<\{\s*(?:(?:#{BLOCK_DIRECTIVES.join('|')}):[^}]*|:(?:#{BLOCK_DIRECTIVES.join('|')})|[@\w.]+)\s*\}>/
18
17
  # A for, if or list directive alone on its line, which leaves no line behind, so a loop's list items stay together.
19
18
  BLOCK_LINE = /^[ \t]*(<\{\s*:?(?:#{BLOCK_DIRECTIVES.join('|')})\b[^}]*\}>)[ \t]*\n/
20
- HIDDEN = /\u{E002}(\d+)\u{E003}/
21
19
 
22
20
  module_function
23
21
 
@@ -31,24 +29,17 @@ module Raindown
31
29
  end
32
30
 
33
31
  def render_body(body, variables, metadata)
34
- hidden = []
35
- # Other directives are hidden, but not the Markdown between their tags.
36
- template = body.gsub(/<\{.*?\}>|<\{/) { |directive| directive.match?(/\A#{DIRECTIVE}\z/) ? directive : hide(directive, hidden) }
37
- template = template.gsub(BLOCK_LINE, '\1')
32
+ template = body.gsub(BLOCK_LINE, '\1')
38
33
  return body unless template.include?('{')
39
34
 
40
35
  # Antlers strips the whitespace around a template, which can be Markdown, like an indented code block.
41
36
  leading = template[/\A\s*/]
42
37
  trailing = template[/\s*\z/]
43
- ast = Antlers.ast(template:, elements: Antlers::Elements[:html, :var, :if, :for] + Elements[:list])
44
- rendered = Antlers.render(ast:, current_binding: variables.binding, metadata:)
38
+ # Components and endpoints render here too, where loop variables are in scope, and keep their HTML as fragments.
39
+ elements = Antlers::Elements[:html, :var, :if, :for] + Elements[:list, :endpoint, :prop_component, :slot_component]
40
+ rendered = Antlers.render(ast: Antlers.ast(template:, elements:), current_binding: variables.binding, metadata:)
45
41
 
46
- "#{leading}#{rendered}#{trailing}".gsub(HIDDEN) { hidden[::Regexp.last_match(1).to_i] }
47
- end
48
-
49
- def hide(directive, hidden)
50
- hidden << directive
51
- "\u{E002}#{hidden.length - 1}\u{E003}"
42
+ "#{leading}#{rendered}#{trailing}"
52
43
  end
53
44
  end
54
45
  end
@@ -21,7 +21,7 @@ module Raindown
21
21
 
22
22
  # Returns true when the file changed.
23
23
  def update(path:, bare: false, execute: false, metadata: NoMetadata)
24
- content = File.read(path)
24
+ content = File.read(path, encoding: Encoding::UTF_8)
25
25
  rewritten = rewrite(content:, path:, bare:, execute:, metadata:)
26
26
  return false if rewritten == content
27
27
 
data/lib/raindown/toc.rb CHANGED
@@ -1,14 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'erb'
4
+ require 'nokogiri'
5
+
4
6
  require_relative 'node'
5
7
 
6
8
  module Raindown
7
9
  # The node behind toc: and :toc, a table of contents of the document's headings.
8
10
  # A project can replace it with its own TOC node in .raindown, like "class TOC < Raindown::TOC".
9
11
  class TOC < Node
10
- def replace(document:, metadata: NoMetadata, bare: false)
11
- headings = Raindown.headings(markdown: document, metadata:, bare:)
12
+ def replace(document:, metadata: nil, bare: false)
13
+ headings = headings(markdown: document, metadata:, bare:)
12
14
  return '' if headings.empty?
13
15
 
14
16
  min_level = headings.map { |heading| heading[:level] }.min
@@ -18,8 +20,9 @@ module Raindown
18
20
  end.join("\n")
19
21
  end
20
22
 
21
- def render(document:, metadata: NoMetadata, bare: false)
22
- items = Raindown.headings(markdown: document, metadata:, bare:).map do |heading|
23
+ # With the document's final HTML, the headings include ones its components render.
24
+ def render(document:, html: nil, metadata: nil, bare: false)
25
+ items = headings(markdown: document, html:, metadata:, bare:).map do |heading|
23
26
  "<li class='h#{heading[:level]}'><a href='##{heading[:id]}'>#{ERB::Util.html_escape(heading[:text])}</a></li>"
24
27
  end
25
28
 
@@ -34,5 +37,14 @@ module Raindown
34
37
  </div>
35
38
  HTML
36
39
  end
40
+
41
+ # The document's headings, from its final HTML, or else by rendering it with its components.
42
+ def headings(markdown:, html: nil, metadata: nil, bare: false)
43
+ html ||= Fragments.collect { Raindown.assembled(markdown:, metadata: metadata || NoMetadata, bare:) }
44
+
45
+ Nokogiri::HTML(html).css(HEADINGS).map do |heading|
46
+ { level: heading.name[1].to_i, text: heading.text.strip, id: heading['id'] }
47
+ end
48
+ end
37
49
  end
38
50
  end
data/lib/raindown.rb CHANGED
@@ -3,13 +3,15 @@
3
3
  require 'commonmarker'
4
4
  require 'antlers'
5
5
  require 'antlers/api'
6
- require 'nokogiri'
6
+
7
7
  require_relative 'elements'
8
8
  require_relative 'raindown/bare_directives'
9
9
  require_relative 'raindown/code'
10
10
  require_relative 'raindown/config'
11
11
  require_relative 'raindown/directives'
12
+ require_relative 'raindown/fragments'
12
13
  require_relative 'raindown/front_matter'
14
+ require_relative 'raindown/heading_ids'
13
15
  require_relative 'raindown/node_blocks'
14
16
  require_relative 'raindown/pages'
15
17
  require_relative 'raindown/pre_renderer'
@@ -36,40 +38,36 @@ module Raindown
36
38
 
37
39
  module_function
38
40
 
39
- def headings(markdown:, metadata: NoMetadata, bare: false)
40
- html = html(markdown:, variables: variables(markdown:), metadata:, bare:)
41
-
42
- Nokogiri::HTML(html).css(HEADINGS).map do |heading|
43
- { level: heading.name[1].to_i, text: heading.text.strip, id: heading['id'] }
44
- end
45
- end
46
-
47
41
  # Directives show in bold, unless the file's project hides them in .raindown/config.yml.
48
42
  def render_file(path:, metadata: NoMetadata, bare: false, hide_directives: Config.for(path)[:hide_directives])
49
- render(markdown: File.read(path), metadata:, bare: bare || BareDirectives.file?(path), hide_directives:)
43
+ render(markdown: File.read(path, encoding: Encoding::UTF_8), metadata:, bare: bare || BareDirectives.file?(path), hide_directives:)
50
44
  end
51
45
 
52
46
  # Front matter keys are variables, like {title} or <{ title }>.
53
- # Directives like a node's show in bold, or as HTML comments with hide_directives.
47
+ # Directives like "toc:" export to HTML as bold or comments depending on hide_directives config.
54
48
  def render(markdown:, metadata: NoMetadata, bare: false, hide_directives: false)
55
- variables = variables(markdown:)
56
- template = html(markdown:, variables:, metadata:, bare:)
57
- context = { document: markdown, metadata:, bare: }
58
-
59
- if template.include?('<{')
60
- # Directives whose output is HTML. The rest rendered before Commonmarker, in PreRenderer.
61
- ast = Antlers.ast(template:, elements: Antlers::Elements[:html, :prop, :slot] + Elements[:endpoint])
62
- template = "#{Antlers.render(ast:, current_binding: variables.binding, metadata:).chomp}\n"
49
+ Fragments.collect do
50
+ # Components' HTML goes back in, then their headings get unique ids, before nodes like the TOC see it.
51
+ html = assembled(markdown:, metadata:, bare:)
52
+ html = Nodes.render_placeholders(html, document: markdown, metadata:, bare:, html:)
53
+
54
+ Directives.render("#{html.chomp}\n", hide: hide_directives)
63
55
  end
56
+ end
64
57
 
65
- # Nodes and directives render last, so their HTML isn't read as Markdown or directives.
66
- Directives.render(Nodes.render_placeholders(template, **context), hide: hide_directives)
58
+ # The document's HTML with its components' HTML put back, and unique heading ids.
59
+ def assembled(markdown:, metadata: NoMetadata, bare: false)
60
+ HeadingIds.unique(Fragments.fill(html(markdown:, variables: variables(markdown:), metadata:, bare:)))
67
61
  end
68
62
 
69
- # The HTML from Commonmarker, after PreRenderer and before the directives whose output is HTML.
70
- def html(markdown:, variables:, metadata:, bare:)
63
+ # The document's HTML from Commonmarker, after nodes and the template pass, with components as fragments.
64
+ def html(markdown:, variables:, metadata: NoMetadata, bare: false)
71
65
  markdown = NodeBlocks.to_rendered(markdown.dup.force_encoding('UTF-8'), metadata:, bare:)
72
- markdown = PreRenderer.render(markdown, variables:, metadata:, bare:)
66
+ commonmark(PreRenderer.render(markdown, variables:, metadata:, bare:))
67
+ end
68
+
69
+ # Markdown to HTML, like a document's or a component's slot.
70
+ def commonmark(markdown)
73
71
  doc = Commonmarker.parse(hide_directives(markdown:), options: MARKDOWN_OPTIONS)
74
72
  preserve_code(doc:)
75
73
 
data/lib/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raindown
4
- VERSION = '0.2.0'
4
+ VERSION = '0.2.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.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - maedi
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-17 00:00:00.000000000 Z
10
+ date: 2026-10-19 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: antlers
@@ -149,6 +149,7 @@ files:
149
149
  - lib/elements.rb
150
150
  - lib/lexemes/endpoint_lexeme.rb
151
151
  - lib/lexemes/list_lexeme.rb
152
+ - lib/nodes/component_nodes.rb
152
153
  - lib/nodes/endpoint_node.rb
153
154
  - lib/nodes/list_node.rb
154
155
  - lib/raindown.rb
@@ -161,7 +162,9 @@ files:
161
162
  - lib/raindown/endpoint/curl.rb
162
163
  - lib/raindown/endpoint/endpoint_runner.rb
163
164
  - lib/raindown/files.rb
165
+ - lib/raindown/fragments.rb
164
166
  - lib/raindown/front_matter.rb
167
+ - lib/raindown/heading_ids.rb
165
168
  - lib/raindown/node.rb
166
169
  - lib/raindown/node_blocks.rb
167
170
  - lib/raindown/node_injector.rb