require 'pathname' require 'markdown_helper/version' class MarkdownHelper class MarkdownHelperError < RuntimeError; end class CircularIncludeError < MarkdownHelperError; end class UnreadableInputError < MarkdownHelperError; end class TocHeadingsError < MarkdownHelperError; end class OptionError < MarkdownHelperError; end class EnvironmentError < MarkdownHelperError; end class InvalidTocTitleError < MarkdownHelperError; end class MisplacedPageTocError < MarkdownHelperError; end class MultiplePageTocError < MarkdownHelperError; end INCLUDE_REGEXP = /^@\[([^\[]+)\]\(([^)]+)\)$/ attr_accessor :pristine def initialize(options = {}) # Confirm that we're in a git project. # This is necessary so that we can prune file paths in the tests, # which otherwise would fail because of differing installation directories. # It also allows pruned paths to be used in the inserted comments (when not pristine). MarkdownHelper.git_clone_dir_path default_options = { :pristine => false, } merged_options = default_options.merge(options) merged_options.each_pair do |method, value| unless self.respond_to?(method) raise OptionError.new("Unknown option: #{method}") end setter_method = "#{method}=" send(setter_method, value) merged_options.delete(method) end end def include(template_file_path, markdown_file_path) send(:generate_file, template_file_path, markdown_file_path, __method__) do |input_lines, output_lines| send(:include_files, template_file_path, input_lines, output_lines, Inclusions.new) end end def create_page_toc(markdown_file_path, toc_file_path) message = <<EOT Method create_page_toc is deprecated. Please use method include with embedded :page_toc treatment. See https://github.com/BurdetteLamar/markdown_helper/blob/master/markdown/use_cases/include_files/include_page_toc/use_case.md#include-page-toc. EOT warn(message) send(:generate_file, markdown_file_path, toc_file_path, __method__) do |input_lines, output_lines| send(:_create_page_toc, input_lines, output_lines) end end private class Heading attr_accessor :level, :title def initialize(level, title) self.level = level self.title = title end def self.parse(line) # Four leading spaces not allowed (but three are allowed). return nil if line.start_with?(' ' * 4) stripped_line = line.sub(/^ */, '') # Now must begin with hash marks and space. return nil unless stripped_line.match(/^#+ /) hash_marks, title = stripped_line.split(' ', 2) level = hash_marks.size # Seventh level heading not allowed. return nil if level > 6 self.new(level, title) end def link remove_regexp = /[\#\(\)\[\]\{\}\.\?\+\*\`\"\']+/ to_hyphen_regexp = /\W+/ anchor = title. gsub(remove_regexp, ''). gsub(to_hyphen_regexp, '-'). downcase "[#{title}](##{anchor})" end end def self.comment(text) "<!--#{text}-->\n" end def generate_file(template_file_path, markdown_file_path, method) unless File.readable?(template_file_path) message = [ Inclusions::UNREADABLE_INPUT_EXCEPTION_LABEL, template_file_path.inspect, ].join("\n") raise UnreadableInputError.new(message) end output_lines = [] File.open(template_file_path, 'r') do |template_file| template_path_in_project = MarkdownHelper.path_in_project(template_file_path) output_lines.push(MarkdownHelper.comment(" >>>>>> BEGIN GENERATED FILE (#{method.to_s}): SOURCE #{template_path_in_project} ")) unless pristine input_lines = template_file.readlines yield input_lines, output_lines output_lines.push(MarkdownHelper.comment(" <<<<<< END GENERATED FILE (#{method.to_s}): SOURCE #{template_path_in_project} ")) unless pristine end File.open(markdown_file_path, 'w') do |file| output_lines.each do |line| file.write(line) end end end def _create_page_toc(input_lines, output_lines) first_heading_level = nil input_lines.each do |input_line| line = input_line.chomp heading = Heading.parse(line) next unless heading first_heading_level ||= heading.level indentation = ' ' * (heading.level - first_heading_level) output_line = "#{indentation}- #{heading.link}" output_lines.push("#{output_line}\n") end end def include_files(includer_file_path, input_lines, output_lines, inclusions) markdown_lines = [] page_toc_inclusion = nil input_lines.each_with_index do |input_line, line_index| match_data = input_line.match(INCLUDE_REGEXP) unless match_data markdown_lines.push(input_line) next end treatment = match_data[1] cited_includee_file_path = match_data[2] new_inclusion = Inclusion.new( input_line.chomp, includer_file_path, line_index + 1, cited_includee_file_path, treatment ) case treatment when ':markdown' inclusions.include( new_inclusion, markdown_lines, self ) when ':page_toc' unless inclusions.inclusions.size == 0 message = 'Page TOC must be in outermost markdown file.' raise MisplacedPageTocError.new(message) end unless page_toc_inclusion.nil? message = 'Only one page TOC allowed.' raise MultiplePageTocError.new(message) end page_toc_inclusion = new_inclusion toc_title = match_data[2] title_regexp = /^\#{1,6}\s/ unless toc_title.match(title_regexp) message = "TOC title must be a valid markdown header, not #{toc_title}" raise InvalidTocTitleError.new(message) end page_toc_inclusion.page_toc_title = toc_title page_toc_inclusion.page_toc_line = input_line markdown_lines.push(input_line) else markdown_lines.push(input_line) end end # If needed, create page TOC and insert into markdown_lines. unless page_toc_inclusion.nil? toc_lines = [ page_toc_inclusion.page_toc_title + "\n", '', ] page_toc_index = markdown_lines.index(page_toc_inclusion.page_toc_line) lines_to_scan = markdown_lines[page_toc_index + 1..-1] _create_page_toc(lines_to_scan, toc_lines) markdown_lines.delete_at(page_toc_index) markdown_lines.insert(page_toc_index, *toc_lines) end # Now review the markdown and include everything. markdown_lines.each_with_index do |markdown_line, line_index| match_data = markdown_line.match(INCLUDE_REGEXP) unless match_data output_lines.push(markdown_line) next end treatment = match_data[1] cited_includee_file_path = match_data[2] new_inclusion = Inclusion.new( markdown_line.chomp, includer_file_path, line_index + 1, cited_includee_file_path, treatment ) inclusions.include( new_inclusion, output_lines, self ) end end def self.git_clone_dir_path git_dir = `git rev-parse --show-toplevel`.chomp unless $?.success? message = <<EOT Markdown helper must run inside a .git project. That is, the working directory one of its parents must be a .git directory. EOT raise RuntimeError.new(message) end git_dir end def self.path_in_project(path) path.sub(MarkdownHelper.git_clone_dir_path + '/', '') end class Inclusions attr_accessor :inclusions def initialize self.inclusions = [] end def include( new_inclusion, output_lines, markdown_helper ) treatment = case new_inclusion.treatment when ':code_block' :code_block when ':markdown' :markdown when ':verbatim' message = "Treatment ':verbatim' is deprecated; please use treatment ':markdown'." warn(message) :markdown when ':comment' :comment when ':pre' :pre else new_inclusion.treatment end if treatment == :markdown check_circularity(new_inclusion) end includee_path_in_project = MarkdownHelper.path_in_project(new_inclusion.absolute_includee_file_path) output_lines.push(MarkdownHelper.comment(" >>>>>> BEGIN INCLUDED FILE (#{treatment}): SOURCE #{includee_path_in_project} ")) unless markdown_helper.pristine begin include_lines = File.readlines(new_inclusion.absolute_includee_file_path) rescue => e inclusions.push(new_inclusion) message = [ MISSING_INCLUDEE_EXCEPTION_LABEL, backtrace_inclusions, ].join("\n") e = UnreadableInputError.new(message) e.set_backtrace([]) raise e end last_line = include_lines.last unless last_line && last_line.match("\n") message = "Warning: Included file has no trailing newline: #{new_inclusion.cited_includee_file_path}" warn(message) end case treatment when :markdown # Pass through unadorned, but honor any nested includes. inclusions.push(new_inclusion) markdown_helper.send(:include_files, new_inclusion.absolute_includee_file_path, include_lines, output_lines, self) inclusions.pop when :comment output_lines.push(MarkdownHelper.comment(include_lines.join(''))) when :pre output_lines.push("<pre>\n") output_lines.push(include_lines.join('')) output_lines.push("</pre>\n") else # Use the file name as a label. file_name_line = format("```%s```:\n", File.basename(new_inclusion.cited_includee_file_path)) output_lines.push(file_name_line) # Put into code block. language = treatment == :code_block ? '' : treatment output_lines.push("```#{language}\n") output_lines.push(*include_lines) output_lines.push("```\n") end output_lines.push(MarkdownHelper.comment(" <<<<<< END INCLUDED FILE (#{treatment}): SOURCE #{includee_path_in_project} ")) unless markdown_helper.pristine end CIRCULAR_EXCEPTION_LABEL = 'Includes are circular:' UNREADABLE_INPUT_EXCEPTION_LABEL = 'Could not read input file.' UNWRITABLE_OUTPUT_EXCEPTION_LABEL = 'Could not write output file.' MISSING_INCLUDEE_EXCEPTION_LABEL = 'Could not read include file,' LEVEL_LABEL = ' Level' BACKTRACE_LABEL = ' Backtrace (innermost include first):' def check_circularity(new_inclusion) previous_inclusions = inclusions.collect {|x| x.real_includee_file_path} previously_included = previous_inclusions.include?(new_inclusion.real_includee_file_path) if previously_included inclusions.push(new_inclusion) message = [ CIRCULAR_EXCEPTION_LABEL, backtrace_inclusions, ].join("\n") e = MarkdownHelper::CircularIncludeError.new(message) e.set_backtrace([]) raise e end end def backtrace_inclusions lines = [BACKTRACE_LABEL] inclusions.reverse.each_with_index do |inclusion, i| lines.push("#{LEVEL_LABEL} #{i}:") level_lines = inclusion.to_lines(indentation_level = 3) lines.push(*level_lines) end lines.join("\n") end end class Inclusion LINE_COUNT = 5 attr_accessor \ :includer_file_path, :includer_line_number, :include_description, :absolute_includee_file_path, :cited_includee_file_path, :treatment, :page_toc_title, :page_toc_line def initialize( include_description, includer_file_path, includer_line_number, cited_includee_file_path, treatment ) self.include_description = include_description self.includer_file_path = includer_file_path self.includer_line_number = includer_line_number self.cited_includee_file_path = cited_includee_file_path self.absolute_includee_file_path = absolute_includee_file_path self.treatment = treatment self.absolute_includee_file_path = File.absolute_path(File.join( File.dirname(includer_file_path), cited_includee_file_path, )) end def real_includee_file_path # Would raise exception unless exists. return nil unless File.exist?(absolute_includee_file_path) Pathname.new(absolute_includee_file_path).realpath.to_s end def indentation(level) ' ' * level end def to_lines(indentation_level) relative_inluder_file_path = MarkdownHelper.path_in_project(includer_file_path) relative_inludee_file_path = MarkdownHelper.path_in_project(absolute_includee_file_path) text = <<EOT #{indentation(indentation_level)}Includer: #{indentation(indentation_level+1)}Location: #{relative_inluder_file_path}:#{includer_line_number} #{indentation(indentation_level+1)}Include description: #{include_description} #{indentation(indentation_level)}Includee: #{indentation(indentation_level+1)}File path: #{relative_inludee_file_path} EOT text.split("\n") end end end