require 'time' require 'cgi' require 'nokogiri' require 'kramdown' require 'ronn/index' require 'ronn/roff' require 'ronn/template' require 'ronn/utils' module Ronn # The Document class can be used to load and inspect a ronn document # and to convert a ronn document into other formats, like roff or # HTML. # # Ronn files may optionally follow the naming convention: # ".
.ronn". The and
are used in # generated documentation unless overridden by the information # extracted from the document's name section. class Document include Ronn::Utils # Path to the Ronn document. This may be '-' or nil when the Ronn::Document # object is created with a stream, in which case stdin will be read. attr_reader :path # Encoding that the Ronn document is in attr_accessor :encoding # The raw input data, read from path or stream and unmodified. attr_reader :data # The index used to resolve man and file references. attr_accessor :index # The man pages name: usually a single word name of # a program or filename; displayed along with the section in # the left and right portions of the header as well as the bottom # right section of the footer. attr_writer :name # The man page's section: a string whose first character # is numeric; displayed in parenthesis along with the name. attr_writer :section # Single sentence description of the thing being described # by this man page; displayed in the NAME section. attr_accessor :tagline # The manual this document belongs to; center displayed in # the header. attr_accessor :manual # The name of the group, organization, or individual responsible # for this document; displayed in the left portion of the footer. attr_accessor :organization # The date the document was published; center displayed in # the document footer. attr_writer :date # Array of style modules to apply to the document. attr_reader :styles # Output directory to write files to. attr_accessor :outdir # Create a Ronn::Document given a path or with the data returned by # calling the block. The document is loaded and preprocessed before # the intialize method returns. The attributes hash may contain values # for any writeable attributes defined on this class. def initialize(path = nil, attributes = {}, &block) @path = path @basename = path.to_s =~ /^-?$/ ? nil : File.basename(path) @reader = block || lambda do |f| if ['-', nil].include?(f) $stdin.read else File.read(f, encoding: @encoding) end end @data = @reader.call(path) @name, @section, @tagline = sniff @styles = %w[man] @manual, @organization, @date = nil @markdown, @input_html, @html = nil @index = Ronn::Index[path || '.'] @index.add_manual(self) if path && name attributes.each { |attr_name, value| send("#{attr_name}=", value) } end # Generate a file basename of the form ".
." # for the given file extension. Uses the name and section from # the source file path but falls back on the name and section # defined in the document. def basename(type = nil) type = nil if ['', 'roff'].include?(type.to_s) [path_name || @name, path_section || @section, type] .compact.join('.') end # Construct a path for a file near the source file. Uses the # Document#basename method to generate the basename part and # appends it to the dirname of the source document. def path_for(type = nil) if @outdir File.join(@outdir, basename(type)) elsif @basename File.join(File.dirname(path), basename(type)) else basename(type) end end # Returns the part of the path, or nil when no path is # available. This is used as the manual page name when the # file contents do not include a name section. def path_name return unless @basename parts = @basename.split('.') parts.pop if parts.length > 1 && parts.last =~ /^\w+$/ parts.pop if parts.last =~ /^\d+$/ parts.join('.') end # Returns the
part of the path, or nil when # no path is available. def path_section $1 if @basename.to_s =~ /\.(\d\w*)\./ end # Returns the manual page name based first on the document's # contents and then on the path name. Usually a single word name of # a program or filename; displayed along with the section in # the left and right portions of the header as well as the bottom # right section of the footer. def name @name || path_name end # Truthful when the name was extracted from the name section # of the document. def name? !@name.nil? end # Returns the manual page section based first on the document's # contents and then on the path name. A string whose first character # is numeric; displayed in parenthesis along with the name. def section @section || path_section end # True when the section number was extracted from the name # section of the document. def section? !@section.nil? end # The name used to reference this manual. def reference_name name + (section && "(#{section})").to_s end # Truthful when the document started with an h1 but did not follow # the "() -- " convention. We assume this is some kind # of custom title. def title? !name? && tagline end # The document's title when no name section was defined. When a name section # exists, this value is nil. def title @tagline unless name? end # The date the man page was published. If not set explicitly, # it first checks for "SOURCE_DATE_EPOCH" to support reproducible # builds, then the file's modified time or, if no file is given, # the current time. Center displayed in the document footer. def date return @date if @date return Time.at(ENV['SOURCE_DATE_EPOCH'].to_i).gmtime if ENV['SOURCE_DATE_EPOCH'] return File.mtime(path) if File.exist?(path) Time.now end # Retrieve a list of top-level section headings in the document and return # as an array of +[id, text]+ tuples, where +id+ is the element's generated # id and +text+ is the inner text of the heading element. def toc @toc ||= html.search('h2[@id]').map { |h2| [h2.attributes['id'].content.upcase, h2.inner_text] } end alias section_heads toc # Styles to insert in the generated HTML output. This is a simple Array of # string module names or file paths. def styles=(styles) @styles = (%w[man] + styles).uniq end # Sniff the document header and extract basic document metadata. Return a # tuple of the form: [name, section, description], where missing information # is represented by nil and any element may be missing. def sniff html = Kramdown::Document.new(data[0, 512], auto_ids: false, smart_quotes: ['apos', 'apos', 'quot', 'quot'], typographic_symbols: { hellip: '...', ndash: '--', mdash: '--' }).to_html heading, html = html.split("\n", 2) return [nil, nil, nil] if html.nil? case heading when /([\w_.\[\]~+=@:-]+)\s*\((\d\w*)\)\s*-+\s*(.*)/ # name(section) -- description [$1, $2, $3] when /([\w_.\[\]~+=@:-]+)\s+-+\s+(.*)/ # name -- description [$1, nil, $2] else # description [nil, nil, heading.sub('

', '')] end end # Preprocessed markdown input text. def markdown @markdown ||= process_markdown! end # A Nokogiri DocumentFragment for the manual content fragment. def html @html ||= process_html! end # Convert the document to :roff, :html, or :html_fragment and # return the result as a string. def convert(format) send "to_#{format}" end # Convert the document to roff and return the result as a string. def to_roff RoffFilter.new( to_html_fragment(nil), name, section, tagline, manual, organization, date ).to_s end # Convert the document to HTML and return the result as a string. # The returned string is a complete HTML document. def to_html layout = ENV.fetch('RONN_LAYOUT', nil) layout_path = nil if layout layout_path = File.expand_path(layout) unless File.exist?(layout_path) warn "warn: can't find #{layout}, using default layout." layout_path = nil end end template = Ronn::Template.new(self) template.context.push html: to_html_fragment(nil) template.render(layout_path || 'default') end # Convert the document to HTML and return the result # as a string. The HTML does not include , , # or