Skip to content

Commit

Permalink
Complete initial implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
wheatevo committed Apr 14, 2019
1 parent e067ca7 commit e6da42c
Show file tree
Hide file tree
Showing 5 changed files with 312 additions and 39 deletions.
3 changes: 3 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
Metrics/BlockLength:
ExcludedMethods: ['describe', 'context']

Metrics/ClassLength:
Max: 200

Metrics/LineLength:
Max: 120

Expand Down
1 change: 1 addition & 0 deletions lib/md_transformer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
module MdTransformer
class Error < ::StandardError; end
class InvalidMarkdownPath < MdTransformer::Error; end
class HeaderTooDeep < MdTransformer::Error; end
end
106 changes: 69 additions & 37 deletions lib/md_transformer/markdown.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@
module MdTransformer
# Class representing a parsed markdown file
class Markdown
include Comparable
include Enumerable

attr_accessor :title
attr_reader :content
attr_reader :children
attr_reader :parent

LOWEST_CHILD_PRECEDENCE = 7
LOWEST_PRECEDENCE = 6

def initialize(source = '', options = {})
@parent = options[:parent]
Expand All @@ -22,32 +27,60 @@ def initialize(source = '', options = {})
end

def content=(value)
parse(value)
# Reflow the content headers based on the child's levels (raise exception if level exceeds 6)
m = Markdown.new(value.to_s)

# Reassign the parent of the children to the current object and ensure the new depth is valid
@children = m.children
@children.each do |c|
c.instance_variable_set(:@parent, self)
validate_levels(c)
end

@content = m.content
end

def keys
@children.map(&:title)
end

def key?(key)
!self[key].nil?
end

def values
@children
end

def value?(value)
!@children.find { |c| c == value }.nil?
end

def dig(key, *rest)
value = self[key]
return value if value.nil? || rest.empty?

value.dig(*rest)
end

# Retrieves the markdown value at a given key if it exists (or nil)
def [](key)
@children.find { |c| c.title == key }
end

# Sets the markdown value at a given key
def []=(key, value)
child = self[key]
unless child
# Key does not exist, create a new child
@children.push(Markdown.new(value, title: key, parent: self))
return
end
child.content = value
child = self[key] || Markdown.new('', title: key, parent: self)
child.content = value.to_s
@children.push(child) unless key?(key)
end

def to_s
# Creates a string representing the markdown document's content
def to_s(options = { title: true })
title_str = @title.to_s.empty? ? '' : "#{'#' * level} #{@title}\n"
"#{title_str}#{@content}#{@children.map(&:to_s).join}"
md_string = "#{options[:title] ? title_str : ''}#{@content}#{@children.map(&:to_s).join}"
md_string << "\n" unless md_string.end_with?("\n")
md_string
end

def write(filename, options: { create_dir: true })
Expand All @@ -65,15 +98,34 @@ def level
@parent.level + 1
end

def each
return enum_for(__method__) unless block_given?
@children.each do |child|
yield child.title, child
end
end

def <=>(other)
to_s <=> other.to_s
end

private

def validate_levels(child)
if child.level >= LOWEST_PRECEDENCE
raise HeaderTooDeep, "#{child.title} header level (h#{child.level}) is beyond h#{LOWEST_PRECEDENCE}"
end

child.children.each { |c| validate_levels(c) }
end

# Parses the provided markdown string content into a nested data structure by header section
def parse(content)
@children = []
@content = ''

# Parse all direct children and create new markdown objects
sections = parse_sections(content)
sections = Section.parse_sections(content)

# No children!
if sections.empty?
Expand All @@ -84,8 +136,12 @@ def parse(content)
# Populate content prior to found headers
@content = content[0..sections.first.location.begin - 1] if sections.first.location.begin > 0

parse_children(sections)
end

def parse_children(sections)
# Go through the headers sequentially to find all direct children (base on header level vs. current level)
last_child_level = LOWEST_CHILD_PRECEDENCE
last_child_level = LOWEST_PRECEDENCE + 1

sections.each do |s|
# Finish parsing if we encounter a sibling (same level) or aunt/uncle (higher level)
Expand All @@ -97,29 +153,5 @@ def parse(content)
end
end
end

def code_blocks(content)
block_regex = /^([`~]{3}.*?^[`~]{3})$/m
content.enum_for(:scan, block_regex).map { Regexp.last_match.begin(0)..Regexp.last_match.end(0) }
end

def parse_sections(content)
code_ranges = code_blocks(content)
header_regex = /^(\#{1,6}\s+.*)$/
header_ranges = content.enum_for(:scan, header_regex).map { Regexp.last_match.begin(0)..Regexp.last_match.end(0) }
header_ranges.reject! { |header_range| code_ranges.any? { |code_range| code_range.include?(header_range.begin) } }
header_ranges.each_with_index do |h, i|
content_end = content.length
header_ranges[i] = { header: h, content: ((h.end + 1)..(content_end - 1)) }
end
header_ranges.map! do |h|
Section.new(
content[h[:header]].match(/^#+\s+(.*)$/)[1],
level: content[h[:header]].split.first.count('#'),
location: h[:header].begin..h[:content].end,
content: content[h[:content]]
)
end
end
end
end
38 changes: 38 additions & 0 deletions lib/md_transformer/markdown/section.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,44 @@ def initialize(title, options = {})
@location = options[:location].nil? ? 0..0 : options[:location]
@content = options[:content] || ''
end

def self.parse_sections(content)
headers = header_ranges(content)
headers.each_with_index do |h, i|
content_end = content.length
headers[i] = { header: h, content: ((h.end + 1)..(content_end - 1)) }
end
headers.map! { |h| create_section_from_header(h, content) }
end

class << self
private

def header_ranges(content)
code_ranges = code_blocks(content)
hdr_regex = /^(\#{1,6}\s+.*)$/
hdr_ranges = content.enum_for(:scan, hdr_regex).map { Regexp.last_match.begin(0)..Regexp.last_match.end(0) }
hdr_ranges.reject { |hdr_range| code_ranges.any? { |code_range| code_range.include?(hdr_range.begin) } }
end

def code_blocks(content)
block_regex = /^([`~]{3}.*?^[`~]{3})$/m
content.enum_for(:scan, block_regex).map { Regexp.last_match.begin(0)..Regexp.last_match.end(0) }
end

def create_section_from_header(header, content)
Section.new(
content[header[:header]].match(/^#+\s+(.*)$/)[1],
level: header_level(content[header[:header]]),
location: header[:header].begin..header[:content].end,
content: content[header[:content]]
)
end

def header_level(header)
header.split.first.count('#')
end
end
end
end
end
Loading

0 comments on commit e6da42c

Please sign in to comment.