require 'pathname' class MarkdownIncluder < MarkdownHelper INCLUDE_REGEXP = /^@\[([^\[]+)\]\(([^)]+)\)$/ INCLUDE_MARKDOWN_REGEXP = /^@\[:markdown\]\(([^)]+)\)$/ def include(template_file_path, markdown_file_path) @inclusions = [] generate_file(:include, template_file_path, markdown_file_path) do |output_lines| Dir.chdir(File.dirname(template_file_path)) do page_toc_lines = get_page_toc_lines(template_file_path) include_all(template_file_path, page_toc_lines, is_nested = false, output_lines) end end end def MarkdownIncluder.pre(text) "
\n#{text}
" end def MarkdownIncluder.details(text) "
\n#{text}
" end def gather_markdown(template_file_path) template_dir_path = File.dirname(template_file_path) template_file_name = File.basename(template_file_path) Dir.chdir(template_dir_path) do check_template(template_file_name) markdown_lines = [] 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) unless treatment == ':markdown' markdown_lines.push(template_line) next end inclusion = Inclusion.new( includer_file_path: template_file_path, include_pragma: template_line, includer_line_number: i, treatment: treatment, cited_includee_file_path: includee_file_path, inclusions: @inclusions ) check_includee(inclusion) check_circularity(inclusion) @inclusions.push(inclusion) includee_lines = gather_markdown(File.absolute_path(includee_file_path)) markdown_lines.concat(includee_lines) @inclusions.pop add_inclusion_comments(treatment, includee_file_path, markdown_lines) end markdown_lines end end def get_page_toc_lines(template_file_path) markdown_lines = gather_markdown(template_file_path) toc_line_index = nil toc_title = nil anchor_counts = Hash.new(0) markdown_lines.each_with_index do |markdown_line, i| match_data = markdown_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 markdown_lines unless toc_line_index toc_lines = [toc_title] first_heading_level = nil markdown_lines.each_with_index do |input_line, i| line = input_line.chomp heading = Heading.parse(line) next unless heading if i < toc_line_index heading.link(anchor_counts) next end first_heading_level ||= heading.level indentation = ' ' * (heading.level - first_heading_level) toc_line = "#{indentation}- #{heading.link(anchor_counts)}" toc_lines.push(toc_line) end toc_lines end def include_all(includer_file_path, page_toc_lines, is_nested, output_lines) includer_dir_path = File.dirname(includer_file_path) includer_file_name = File.basename(includer_file_path) absolute_includer_file_path = File.absolute_path(includer_file_path) Dir.chdir(includer_dir_path) do check_template(includer_file_name) template_lines = File.readlines(includer_file_name) if is_nested add_inclusion_comments(':markdown', includer_file_path, template_lines) end 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 if treatment == ':page_toc' output_lines.concat(page_toc_lines) next end inclusion = Inclusion.new( includer_file_path: absolute_includer_file_path, include_pragma: template_line, includer_line_number: i, treatment: treatment, cited_includee_file_path: includee_file_path, inclusions: @inclusions ) @inclusions.push(inclusion) check_includee(inclusion) case treatment when ':comment' include_comment(includee_file_path, treatment, inclusion, output_lines) when ':pre' include_pre(includee_file_path, treatment, inclusion, output_lines) when ':details' include_details(includee_file_path, treatment, inclusion, output_lines) when ':markdown' include_all(includee_file_path, page_toc_lines, is_nested = true, output_lines) else include_file(includee_file_path, treatment, inclusion, output_lines) end @inclusions.pop end end end def include_comment(includee_file_path, treatment, inclusion, output_lines) text = File.read(includee_file_path) output_lines.push(MarkdownHelper.comment(text)) add_inclusion_comments(treatment, includee_file_path, output_lines) end def include_pre(includee_file_path, treatment, inclusion, output_lines) text = File.read(includee_file_path) output_lines.push(MarkdownIncluder.pre(text)) add_inclusion_comments(treatment, includee_file_path, output_lines) end def include_details(includee_file_path, treatment, inclusion, output_lines) text = File.read(includee_file_path) output_lines.push(MarkdownIncluder.details(text)) add_inclusion_comments(treatment, includee_file_path, output_lines) end def include_file(includee_file_path, treatment, inclusion, output_lines) 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, includee_file_path, includee_lines) output_lines.concat(includee_lines) end def check_template(template_file_path) 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}", MarkdownIncluder.backtrace_inclusions(@inclusions), ].join("\n") e = UnreadableTemplateError.new(message) e.set_backtrace([]) raise e end end def add_inclusion_comments(treatment, includee_file_path, lines) unless pristine path_in_project = MarkdownHelper.path_in_project(includee_file_path) treatment = treatment.sub(/^:/, '') 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 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(anchor_counts = Hash.new(0)) anchor = title.downcase anchor.gsub!(/[^\p{Word}\- ]/u, '') # remove punctuation anchor.gsub!(' ', '-') # replace spaces with dash anchor_count = anchor_counts[anchor] anchor_counts[anchor] += 1 suffix = (anchor_count == 0) ? '' : "-#{anchor_count}" "[#{title}](##{anchor}#{suffix})" 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:', MarkdownIncluder.backtrace_inclusions(@inclusions), ].join("\n") e = 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:', MarkdownIncluder.backtrace_inclusions(@inclusions), ].join("\n") e = 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 = <