lib/asciidoctor/converter.rb



# frozen_string_literal: true
module Asciidoctor
# A module for defining converters that are used to convert {AbstractNode} objects in a parsed AsciiDoc document to an
# output (aka backend) format such as HTML or DocBook.
#
# A {Converter} is typically instantiated each time an AsciiDoc document is processed (i.e., parsed and converted).
# Implementing a custom converter entails:
#
# * Including the {Converter} module in a converter class and implementing the {Converter#convert} method or extending
#   the {Converter::Base Base} class and implementing the dispatch methods that map to each node.
# * Optionally registering the converter with one or more backend names statically using the +register_for+ DSL method
#   contributed by the {Converter::Config Config} module.
#
# Examples
#
#   class TextConverter
#     include Asciidoctor::Converter
#     register_for 'text'
#     def initialize *args
#       super
#       outfilesuffix '.txt'
#     end
#     def convert node, transform = node.node_name, opts = nil
#       case transform
#       when 'document', 'section'
#         [node.title, node.content].join %(\n\n)
#       when 'paragraph'
#         (node.content.tr ?\n, ' ') << ?\n
#       else
#         (transform.start_with? 'inline_') ? node.text : node.content
#       end
#     end
#   end
#   puts Asciidoctor.convert_file 'sample.adoc', backend: :text, safe: :safe
#
#   class Html5Converter < (Asciidoctor::Converter.for 'html5')
#     register_for 'html5'
#     def convert_paragraph node
#       %(<p>#{node.content}</p>)
#     end
#   end
#   puts Asciidoctor.convert_file 'sample.adoc', safe: :safe
module Converter
  autoload :CompositeConverter, %(#{__dir__}/converter/composite)
  autoload :TemplateConverter, %(#{__dir__}/converter/template) unless RUBY_ENGINE == 'opal'

  # Public: The String backend name that this converter is handling.
  attr_reader :backend

  # Public: Creates a new instance of this {Converter}.
  #
  # backend - The String backend name (aka format) to which this converter converts.
  # opts    - An options Hash (optional, default: {})
  #
  # Returns a new [Converter] instance.
  def initialize backend, opts = {}
    @backend = backend
  end

  # Public: Converts an {AbstractNode} using the given transform.
  #
  # This method must be implemented by a concrete converter class.
  #
  # node      - The concrete instance of AbstractNode to convert.
  # transform - An optional String transform that hints at which transformation should be applied to this node. If a
  #             transform is not given, the transform is often derived from the value of the {AbstractNode#node_name}
  #             property. (optional, default: nil)
  # opts      - An optional Hash of options hints about how to convert the node. (optional, default: nil)
  #
  # Returns the [String] result.
  def convert node, transform = nil, opts = nil
    raise ::NotImplementedError, %(#{self.class} (backend: #{@backend}) must implement the ##{__method__} method)
  end

  # Public: Reports whether the current converter is able to convert this node (by its transform name). Used by the
  # {CompositeConverter} to select which converter to use to handle a given node. Returns true by default.
  #
  # transform - the String name of the node transformation (typically the node name).
  #
  # Returns a [Boolean] indicating whether this converter can handle the specified transform.
  def handles? transform
    true
  end

  # Public: Derive backend traits (basebackend, filetype, outfilesuffix, htmlsyntax) from the given backend.
  #
  # backend - the String backend from which to derive the traits
  # basebackend - the String basebackend to use in favor of deriving one from the backend (optional, default: nil)
  #
  # Returns the backend traits for the given backend as a [Hash].
  def self.derive_backend_traits backend, basebackend = nil
    return {} unless backend
    if (outfilesuffix = DEFAULT_EXTENSIONS[(basebackend ||= backend.sub TrailingDigitsRx, '')])
      filetype = outfilesuffix.slice 1, outfilesuffix.length
    else
      outfilesuffix = %(.#{filetype = basebackend})
    end
    filetype == 'html' ?
      { basebackend: basebackend, filetype: filetype, htmlsyntax: 'html', outfilesuffix: outfilesuffix } :
      { basebackend: basebackend, filetype: filetype, outfilesuffix: outfilesuffix }
  end

  module BackendTraits
    def basebackend value = nil
      value ? ((backend_traits value)[:basebackend] = value) : backend_traits[:basebackend]
    end

    def filetype value = nil
      value ? (backend_traits[:filetype] = value) : backend_traits[:filetype]
    end

    def htmlsyntax value = nil
      value ? (backend_traits[:htmlsyntax] = value) : backend_traits[:htmlsyntax]
    end

    def outfilesuffix value = nil
      value ? (backend_traits[:outfilesuffix] = value) : backend_traits[:outfilesuffix]
    end

    def supports_templates value = true
      backend_traits[:supports_templates] = value
    end

    def supports_templates?
      backend_traits[:supports_templates]
    end

    def init_backend_traits value = nil
      @backend_traits = value || {}
    end

    def backend_traits basebackend = nil
      @backend_traits ||= Converter.derive_backend_traits @backend, basebackend
    end

    alias backend_info backend_traits

    # Deprecated: Use {Converter.derive_backend_traits} instead.
    def self.derive_backend_traits backend, basebackend = nil
      Converter.derive_backend_traits backend, basebackend
    end
  end

  # A module that contributes the +register_for+ method for registering a converter with the default registry.
  module Config
    # Public: Registers this {Converter} class with the default registry to handle the specified backend name(s).
    #
    # backends - One or more String backend names with which to associate this {Converter} class.
    #
    # Returns nothing.
    def register_for *backends
      Converter.register self, *(backends.map {|backend| backend.to_s })
    end
  end

  # A reusable module for registering and instantiating {Converter Converter} classes used to convert an {AbstractNode}
  # to an output (aka backend) format such as HTML or DocBook.
  #
  # {Converter Converter} objects are instantiated by passing a String backend name and, optionally, an options Hash to
  # the {Factory#create} method. The backend can be thought of as an intent to convert a document to a specified format.
  #
  # Applications interact with the factory either through the global, static registry mixed into the {Converter
  # Converter} module or a concrete class that includes this module such as {CustomFactory}. For example:
  #
  # Examples
  #
  #   converter = Asciidoctor::Converter.create 'html5', htmlsyntax: 'xml'
  module Factory
    # Public: Create an instance of DefaultProxyFactory or CustomFactory, depending on whether the proxy_default keyword
    # arg is set (true by default), and optionally seed it with the specified converters map. If proxy_default is set,
    # entries in the proxy registry are preferred over matching entries from the default registry.
    #
    # converters    - An optional Hash of converters to use in place of ones in the default registry. The keys are
    #                 backend names and the values are converter classes or instances.
    # proxy_default - A Boolean keyword arg indicating whether to proxy the default registry (optional, default: true).
    #
    # Returns a Factory instance (DefaultFactoryProxy or CustomFactory) seeded with the optional converters map.
    def self.new converters = nil, proxy_default: true
      proxy_default ? (DefaultFactoryProxy.new converters) : (CustomFactory.new converters)
    end

    # Deprecated: Maps the old default factory instance holder to the Converter module.
    def self.default *args
      Converter
    end

    # Deprecated: Maps the create method on the old default factory instance holder to the Converter module.
    def self.create backend, opts = {}
      default.create backend, opts
    end

    # Public: Register a custom converter with this factory to handle conversion for the specified backends. If the
    # backend is an asterisk (i.e., +*+), the converter will handle any backend for which a converter is not registered.
    #
    # converter - The Converter class to register.
    # backends  - One or more String backend names that this converter should be registered to handle.
    #
    # Returns nothing
    def register converter, *backends
      backends.each {|backend| backend == '*' ? (registry.default = converter) : (registry[backend] = converter) }
    end

    # Public: Lookup the custom converter registered with this factory to handle the specified backend.
    #
    # backend - The String backend name.
    #
    # Returns the [Converter] class registered to convert the specified backend or nil if no match is found.
    def for backend
      registry[backend]
    end

    # Public: Create a new Converter object that can be used to convert {AbstractNode}s to the format associated with
    # the backend. This method accepts an optional Hash of options that are passed to the converter's constructor.
    #
    # If a custom Converter is found to convert the specified backend, it's instantiated (if necessary) and returned
    # immediately. If a custom Converter is not found, an attempt is made to find a built-in converter. If the
    # +:template_dirs+ key is found in the Hash passed as the second argument, a {CompositeConverter} is created that
    # delegates to a {TemplateConverter} and, if found, the built-in converter. If the +:template_dirs+ key is not
    # found, the built-in converter is returned or nil if no converter is found.
    #
    # backend - the String backend name.
    # opts    - a Hash of options to customize creation; also passed to the converter's constructor:
    #           :template_dirs - a String Array of directories used to instantiate a {TemplateConverter} (optional).
    #           :delegate_backend - a backend String of the last converter in the {CompositeConverter} chain (optional).
    #
    # Returns the [Converter] instance.
    def create backend, opts = {}
      if (converter = self.for backend)
        converter = converter.new backend, opts if ::Class === converter
        if (template_dirs = opts[:template_dirs]) && BackendTraits === converter && converter.supports_templates?
          CompositeConverter.new backend, (TemplateConverter.new backend, template_dirs, opts), converter, backend_traits_source: converter
        else
          converter
        end
      elsif (template_dirs = opts[:template_dirs])
        if (delegate_backend = opts[:delegate_backend]) && (converter = self.for delegate_backend)
          converter = converter.new delegate_backend, opts if ::Class === converter
          CompositeConverter.new backend, (TemplateConverter.new backend, template_dirs, opts), converter, backend_traits_source: converter
        else
          TemplateConverter.new backend, template_dirs, opts
        end
      end
    end

    # Public: Get the Hash of Converter classes keyed by backend name. Intended for testing only.
    def converters
      registry.merge
    end

    private

    def registry
      raise ::NotImplementedError, %(#{Factory} subclass #{self.class} must implement the ##{__method__} method)
    end
  end

  class CustomFactory
    include Factory

    def initialize seed_registry = nil
      if seed_registry
        seed_registry.default = seed_registry.delete '*'
        @registry = seed_registry
      else
        @registry = {}
      end
    end

    # Public: Unregister all Converter classes that are registered with this factory. Intended for testing only.
    #
    # Returns nothing.
    def unregister_all
      registry.clear.default = nil
    end

    private

    attr_reader :registry
  end

  # Mixed into the {Converter} module to provide the global registry of converters that are registered statically.
  #
  # This registry includes built-in converters for {Html5Converter HTML 5}, {DocBook5Converter DocBook 5} and
  # {ManPageConverter man(ual) page}, as well as any custom converters that have been discovered or explicitly
  # registered. Converter registration is synchronized (where applicable) and is thus guaranteed to be thread safe.
  module DefaultFactory
    include Factory

    private

    @@registry = {}

    def registry
      @@registry
    end

    unless RUBY_ENGINE == 'opal' # the following block adds support for synchronization and lazy registration
      public

      def register converter, *backends
        if @@mutex.owned?
          backends.each {|backend| backend == '*' ? (@@catch_all = converter) : (@@registry = @@registry.merge backend => converter) }
        else
          @@mutex.synchronize { register converter, *backends }
        end
      end

      def unregister_all
        @@mutex.synchronize do
          @@registry = @@registry.select {|backend| PROVIDED[backend] }
          @@catch_all = nil
        end
      end

      def for backend
        @@registry.fetch backend do
          PROVIDED[backend] ? (@@mutex.synchronize do
            # require is thread-safe, so no reason to refetch
            require PROVIDED[backend]
            @@registry[backend]
          end) : catch_all
        end
      end

      PROVIDED = {
        'docbook5' => %(#{__dir__}/converter/docbook5),
        'html5' => %(#{__dir__}/converter/html5),
        'manpage' => %(#{__dir__}/converter/manpage),
      }

      private

      def catch_all
        @@catch_all
      end

      @@catch_all = nil
      @@mutex = ::Mutex.new
    end
  end

  class DefaultFactoryProxy < CustomFactory
    include DefaultFactory # inserts module into ancestors immediately after superclass

    unless RUBY_ENGINE == 'opal'
      def unregister_all
        super
        @registry.clear.default = nil
      end

      def for backend
        @registry.fetch(backend) { super }
      end

      private

      def catch_all
        @registry.default || super
      end
    end
  end

  # Internal: Mixes the {Config} module into any class that includes the {Converter} module. Additionally, mixes the
  # {BackendTraits} method into instances of this class.
  #
  # into - The Class into which the {Converter} module is being included.
  #
  # Returns nothing.
  def self.included into
    into.send :include, BackendTraits
    into.extend Config
  end
  private_class_method :included # use separate declaration for Ruby 2.0.x

  # An abstract base class for defining converters that can be used to convert {AbstractNode} objects in a parsed
  # AsciiDoc document to a backend format such as HTML or DocBook.
  class Base
    include Logging
    include Converter

    # Public: Converts an {AbstractNode} by delegating to a method that matches the transform value.
    #
    # This method looks for a method whose name matches the transform prefixed with "convert_" to dispatch to. If the
    # +opts+ argument is non-nil, this method assumes the dispatch method accepts two arguments, the node and an options
    # Hash. The options Hash may be used by converters to delegate back to the top-level converter. Currently, this
    # feature is used for the outline transform. If the +opts+ argument is nil, this method assumes the dispatch method
    # accepts the node as its only argument.
    #
    # See {Converter#convert} for details about the arguments and return value.
    def convert node, transform = node.node_name, opts = nil
      opts ? (send 'convert_' + transform, node, opts) : (send 'convert_' + transform, node)
    rescue
      raise unless ::NoMethodError === (ex = $!) && ex.receiver == self && ex.name.to_s == transform
      logger.warn %(missing convert handler for #{ex.name} node in #{@backend} backend (#{self.class}))
      nil
    end

    def handles? transform
      respond_to? %(convert_#{transform})
    end

    # Public: Converts the {AbstractNode} using only its converted content.
    #
    # Returns the converted [String] content.
    def content_only node
      node.content
    end

    # Public: Skips conversion of the {AbstractNode}.
    #
    # Returns nothing.
    def skip node; end
  end

  extend DefaultFactory # exports static methods
end
end