require 'pathname' require 'markdown_helper/version' class MarkdownHelper class MarkdownHelperError < RuntimeError; end class OptionError < MarkdownHelperError; end class MultiplePageTocError < MarkdownHelperError; end class InvalidTocTitleError < MarkdownHelperError; end class UnreadableTemplateError < MarkdownHelperError; end class UnwritableMarkdownError < MarkdownHelperError; end class CircularIncludeError < MarkdownHelperError; end class UnreadableIncludeeError < MarkdownHelperError; end INCLUDE_REGEXP = /^@\[([^\[]+)\]\(([^)]+)\)$/ INCLUDE_MARKDOWN_REGEXP = /^@\[:markdown\]\(([^)]+)\)$/ 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 @inclusions = [] end def include(template_file_path, markdown_file_path) send(:generate_file, template_file_path, markdown_file_path) do |output_lines| Dir.chdir(File.dirname(template_file_path)) do markdown_lines = include_markdown(template_file_path) markdown_lines = include_page_toc(markdown_lines) include_all(template_file_path, markdown_lines, output_lines) end end end private def generate_file(template_file_path, markdown_file_path) template_path_in_project = MarkdownHelper.path_in_project(template_file_path) output_lines = [] yield output_lines unless pristine output_lines.unshift(MarkdownHelper.comment(" >>>>>> BEGIN GENERATED FILE (include): SOURCE #{template_path_in_project} ")) output_lines.push(MarkdownHelper.comment(" <<<<<< END GENERATED FILE (include): SOURCE #{template_path_in_project} ")) end output_lines.push('') output = output_lines.join("\n") File.write(markdown_file_path, output) end def include_markdown(template_file_path) Dir.chdir(File.dirname(template_file_path)) do markdown_lines = [] unless File.readable?(template_file_path) path_in_project = MarkdownHelper.path_in_project(template_file_path ) message = [ "Could not read template file: #{path_in_project}", MarkdownHelper.backtrace_inclusions(@inclusions), ].join("\n") e = UnreadableTemplateError.new(message) e.set_backtrace([]) raise e end template_lines = File.readlines(template_file_path) template_lines.each_with_index do |template_line, i| template_line.chomp! treatment, includee_file_path = *parse_include(template_line) if treatment.nil? markdown_lines.push(template_line) next end if treatment == ':page_toc' markdown_lines.push(template_line) next end inclusion = Inclusion.new( template_file_path, template_line, i, treatment, includee_file_path, @inclusions ) case treatment when ':markdown' check_includee(inclusion) check_circularity(inclusion) @inclusions.push(inclusion) includee_lines = include_markdown(File.absolute_path(includee_file_path)) markdown_lines.concat(includee_lines) when ':comment' text = File.read(includee_file_path) markdown_lines.push(MarkdownHelper.comment(text)) @inclusions.push(inclusion) when ':pre' text = File.read(includee_file_path) markdown_lines.push(MarkdownHelper.pre(text)) @inclusions.push(inclusion) when ':details' text = File.read(includee_file_path) markdown_lines.push(MarkdownHelper.details(text)) @inclusions.push(inclusion) else markdown_lines.push(template_line) next end @inclusions.pop treatment.sub!(/^:/, '') add_inclusion_comments(treatment, includee_file_path, markdown_lines) end markdown_lines end end def include_page_toc(template_lines) toc_line_index = nil toc_title = nil template_lines.each_with_index do |template_line, i| match_data = template_line.match(INCLUDE_REGEXP) next unless match_data treatment = match_data[1] next unless treatment == ':page_toc' unless toc_line_index.nil? message = 'Multiple page TOC not allowed' raise MultiplePageTocError.new(message) end toc_line_index = i 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 end return template_lines unless toc_line_index toc_lines = [toc_title] first_heading_level = nil template_lines.each_with_index do |input_line, i| next if i < toc_line_index line = input_line.chomp heading = Heading.parse(line) next unless heading first_heading_level ||= heading.level indentation = ' ' * (heading.level - first_heading_level) toc_line = "#{indentation}- #{heading.link}" toc_lines.push(toc_line) end template_lines.delete_at(toc_line_index) template_lines.insert(toc_line_index, *toc_lines) template_lines end def include_all(template_file_path, template_lines, output_lines) template_lines.each_with_index do |template_line, i| treatment, includee_file_path = *parse_include(template_line) if treatment.nil? output_lines.push(template_line) next end inclusion = Inclusion.new( template_file_path, template_line, i, treatment, includee_file_path, @inclusions ) check_includee(inclusion) @inclusions.push(inclusion) file_marker = format('```%s```:', File.basename(includee_file_path)) begin_backticks = '```' end_backticks = '```' begin_backticks += treatment unless treatment.start_with?(':') includee_lines = File.read(includee_file_path).split("\n") includee_lines.unshift(begin_backticks) includee_lines.unshift(file_marker) includee_lines.push(end_backticks) add_inclusion_comments(treatment.sub(':', ''), includee_file_path, includee_lines) output_lines.concat(includee_lines) end end def add_inclusion_comments(treatment, includee_file_path, lines) path_in_project = MarkdownHelper.path_in_project(includee_file_path) unless pristine comment = format(' >>>>>> BEGIN INCLUDED FILE (%s): SOURCE %s ', treatment, path_in_project) lines.unshift(MarkdownHelper.comment(comment)) comment = format(' <<<<<< END INCLUDED FILE (%s): SOURCE %s ', treatment, path_in_project) lines.push(MarkdownHelper.comment(comment)) end end def parse_include(line) match_data = line.match(INCLUDE_REGEXP) return [nil, nil] unless match_data treatment = match_data[1] includee_file_path = match_data[2] [treatment, includee_file_path] end def self.path_in_project(file_path) abs_path = File.absolute_path(file_path) abs_path.sub(self.git_clone_dir_path + '/', '') end def self.comment(text) "" end def self.pre(text) "
\n#{text}
" end def self.details(text) "
\n#{text}
" end def self.git_clone_dir_path git_dir = `git rev-parse --show-toplevel`.chomp unless $?.success? message = < 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 check_circularity(inclusion) included_file_paths = @inclusions.collect { |x| x.includee_real_file_path} previously_included = included_file_paths.include?(inclusion.includee_real_file_path) if previously_included @inclusions.push(inclusion) message = [ 'Includes are circular:', MarkdownHelper.backtrace_inclusions(@inclusions), ].join("\n") e = MarkdownHelper::CircularIncludeError.new(message) e.set_backtrace([]) raise e end end def check_includee(inclusion) unless File.readable?(inclusion.includee_absolute_file_path) @inclusions.push(inclusion) message = [ 'Could not read includee file:', MarkdownHelper.backtrace_inclusions(@inclusions), ].join("\n") e = MarkdownHelper::UnreadableIncludeeError.new(message) e.set_backtrace([]) raise e end end def self.backtrace_inclusions(inclusions) lines = [' Backtrace (innermost include first):'] inclusions.reverse.each_with_index do |inclusion, i| lines.push("#{' Level'} #{i}:") level_lines = inclusion.to_lines(indentation_level = 3) lines.push(*level_lines) end lines.join("\n") end class Inclusion attr_accessor \ :includer_file_path, :includer_absolute_file_path, :include_pragma, :treatment, :includer_line_number, :cited_includee_file_path, :includee_absolute_file_path def initialize( includer_file_path, include_pragma, includer_line_number, treatment, cited_includee_file_path, inclusions ) self.includer_file_path = includer_file_path self.include_pragma = include_pragma self.includer_line_number = includer_line_number self.treatment = treatment self.cited_includee_file_path = cited_includee_file_path self.includer_absolute_file_path = File.absolute_path(includer_file_path) unless File.exist?(self.includer_absolute_file_path) fail self.includer_absolute_file_path end self.includee_absolute_file_path = File.absolute_path(File.join( File.dirname(includer_file_path), cited_includee_file_path, )) end def includer_real_file_path Pathname.new(includer_absolute_file_path).realpath.to_s end def includee_real_file_path Pathname.new(includee_absolute_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(includee_absolute_file_path) text = <