razor-x/palimpsest

View on GitHub
lib/palimpsest/environment.rb

Summary

Maintainability
D
2 days
Test Coverage
require 'active_support/core_ext/hash'
require 'tmpdir'
require 'yaml'

module Palimpsest
  # An environment is populated with the contents of
  # a site's repository at a specified commit.
  # Alternatively, a single directory can be used to populate the environment.
  # The environment's files are rooted in a temporary {#directory}.
  # An environment is the primary way to interact with a site's files.
  #
  # An environment loads a {#config} file from the working {#directory};
  # by default, `palimpsest.yml`.
  #
  # Paths are all relative to the working {#directory}.
  #
  # ````yml
  # # example of palimpsest.yml
  #
  # # persistent files and directories
  # persistent:
  #   - config.php
  #   - data/
  #
  # # component settings
  # components:
  #   # all component paths are relative to the base
  #   base: _components
  #
  #   # list of components
  #   paths:
  #    #- [ components_path, install_path ]
  #     - [ my_app/templates, apps/my_app/templates ]
  #     - [ my_app/extra, apps/my_app ]
  #
  # # externals settings
  # externals:
  #   # server or local path that repositories are under
  #   server: "https://github.com/razor-x"
  #
  #   # list of external repositories
  #   repositories:
  #       # uses repository at https://github.com/razor-x/my_app
  #       # and installs to apps/my_app
  #     - name: my_app
  #       path: apps/my_app
  #
  #       # uses repository at https://bitbucket.org/razorx/sub_app
  #       # and installs to apps/my_app from git reference v1.0.0
  #     - name: sub_app
  #       path: apps/my_app/sub_app
  #       reference: v1.0.0
  #       server: apps/my_app
  #
  # # list of excludes
  # # matching paths are removed with {#remove_excludes}.
  # excludes:
  #   - _assets
  #   - apps/*/.gitignore
  #
  # # asset settings
  # assets:
  #   # all options are passed to Assets#options
  #   # options will use defaults set in Palimpsest::Asset::DEFAULT_OPTIONS if unset here
  #   # unless otherwise mentioned, options can be set or overridden per asset type
  #   options:
  #     # opening and closing brackets for asset source tags
  #     # global option only: cannot be overridden per asset type
  #     src_pre: "[%"
  #     src_post: "%]"
  #
  #     # relative directory to save compiled assets
  #     output: compiled
  #
  #     # assume assets will be served under here
  #     cdn: https://cdn.example.com/
  #
  #     # compiled asset names include a uniqe hash by default
  #     # this can be toggled off
  #     hash: false
  #
  #   # directories to scan for files with asset tags
  #   sources:
  #     # putting assets/stylesheets first would allow asset tags,
  #     # e.g. for images, to be used in your stylesheets
  #     - assets/stylesheets
  #     - public
  #     - app/src
  #
  #   # all other keys are asset types
  #   javascripts:
  #     options:
  #       js_compressor: :uglifier
  #     # these paths are loaded into the sprockets environment
  #     paths:
  #       - assets/javascripts
  #       - other/javascripts
  #
  #   # this is another asset type which will have it's own namespace
  #   stylesheets:
  #     options:
  #       css_compressor: :sass
  #
  #     paths:
  #       - assets/stylesheets
  #   # images can be part of the asset pipeline
  #   images:
  #     options:
  #       # requires the sprockets-image_compressor gem
  #       image_compression: true
  #       # options can be overridden per type
  #       output: images
  #     paths:
  #       - assets/images
  # ````
  #
  # Disable some cops until they can be refactored in the rubocop branch.
  # rubocop:disable Metrics/ClassLength, Metrics/MethodLength
  # rubocop:disable Metrics/BlockNesting, Style/Next, Metrics/CyclomaticComplexity
  class Environment
    # Default {#options}.
    DEFAULT_OPTIONS = {
      # Backend to use for file search operations.
      # :grep to use grep.
      search_backend: :grep,

      # Backend to use for multi-file copy operations.
      # :rsync to use rsync.
      copy_backend: :rsync,

      # Files and directories that should never
      # be copied to the working environment.
      copy_exclude: %w(.git .svn),

      # Directory to store cached repository clones.
      repo_cache_root: Palimpsest::Repo::CACHE,

      # Skip updating each external repository.
      skip_external_repo_update: false,

      # All environment's temporary directories will be rooted under here.
      tmp_dir: Dir.tmpdir,

      # Prepended to the name of the environment's working directory.
      dir_prefix: 'palimpsest_',

      # Name of config file to load, relative to environment's working directory.
      config_file: 'palimpsest.yml'
    }

    # @!attribute site
    #   @return site to build the environment with
    #
    # @!attribute reference
    #   @return [String] the reference used to pick the commit to build the environment with
    #
    # @!attribute [r] populated
    #   @return [Boolean] true if the site's repository has been extracted
    attr_reader :site, :reference, :populated

    def initialize(site: nil, reference: 'master', options: {})
      @populated = false
      self.options options
      self.site = site if site
      self.reference = reference
    end

    # Uses {DEFAULT_OPTIONS} as initial value.
    # @param options [Hash] merged with current options
    # @return [Hash] current options
    def options(options = {})
      @options ||= DEFAULT_OPTIONS
      @options = @options.merge options
    end

    # @see Environment#site
    def site=(site)
      fail "Cannot redefine 'site' once populated" if populated
      @site = site
    end

    # @see Environment#reference
    def reference=(reference)
      fail "Cannot redefine 'reference' once populated" if populated
      fail TypeError unless reference.is_a? String
      @reference = reference
    end

    # The corresponding {Repo} for this environment.
    def repo
      @repo = nil if site.repository != @repo.source unless @repo.nil?
      @repo = nil if options[:repo_cache_root] != @repo.cache unless @repo.nil?
      @repo ||= Repo.new.tap do |r|
        r.cache = options[:repo_cache_root]
        r.source = site.repository unless site.nil?
      end
    end

    # @return [String] the environment's working directory
    def directory
      @directory ||= Dir.mktmpdir(
        "#{options[:dir_prefix]}#{site.nil? ? '' : site.name}_",
        options[:tmp_dir]
      )
    end

    # Copy the contents of the working directory.
    # @param destination [String] path to copy environment's files to
    # @param mirror [Boolean] remove any non-excluded paths from destination
    # @return [Environment] the current environment instance
    def copy(destination: site.path, mirror: false)
      fail 'Must specify a destination' if destination.nil?
      exclude = options[:copy_exclude]
      exclude.concat config[:persistent] unless config[:persistent].nil?
      Utils.copy_directory directory, destination, exclude: exclude, mirror: mirror
      self
    end

    # Removes the environment's working directory.
    # @return [Environment] the current environment instance
    def cleanup
      FileUtils.remove_entry_secure directory if @directory
      @config = nil
      @directory = nil
      @assets = []
      @components = []
      @populated = false
      self
    end

    # Extracts the site's files from repository to the working directory.
    # @return [Environment] the current environment instance
    def populate(from: :auto)
      return if populated
      fail "Cannot populate without 'site'" if site.nil?

      case from
      when :auto
        if site.respond_to?(:repository) ? site.repository : nil
          populate from: :repository
        else
          populate from: :source
        end
      when :repository
        fail "Cannot populate without 'reference'" if reference.empty?
        repo.extract directory, reference: reference
        @populated = true
      when :source
        source = site.source.nil? ? '.' : site.source
        Utils.copy_directory source, directory, exclude: options[:copy_exclude]
        @populated = true
      end

      self
    end

    # @param settings [Hash] merged with current config
    # @return [Hash] configuration loaded from {#options}`[:config_file]` under {#directory}
    def config(settings = {})
      settings = ActiveSupport::HashWithIndifferentAccess.new settings

      if @config.nil?
        populate unless populated
        file = File.join(directory, options[:config_file])
        @config = YAML.load_file(file) if File.exist? file
        @config = ActiveSupport::HashWithIndifferentAccess.new @config
        validate_config if @config
      end

      @config.nil? ? settings : @config.deep_merge!(settings)
    end

    # Runs all compile tasks.
    # @return [Environment] the current environment instance
    def compile
      populate
      install_externals
      install_components
      compile_assets
      remove_excludes
      self
    end

    # @return [Array<Component>] components with paths loaded from config
    def components
      return @components if @components
      return [] if config[:components].nil?
      return [] if config[:components][:paths].nil?

      @components = []

      base = directory
      base = File.join directory, config[:components][:base] unless config[:components][:base].nil?

      config[:components][:paths].each do |paths|
        @components << Component.new.tap do |c|
          c.source_path = File.join(base, paths[0])
          c.install_path = File.join(directory, paths[1])
        end
      end

      @components
    end

    # Install all components.
    # @return [Environment] the current environment instance
    def install_components
      components.each(&:install)
      self
    end

    # @return [Array<External>] externals loaded from config
    def externals
      return @externals if @externals
      return [] if config[:externals].nil?
      return [] if config[:externals][:repositories].nil?

      @externals = []

      config[:externals][:repositories].each do |repo|
        @externals << External.new.tap do |e|
          e.name = repo[:name]
          e.source = repo[:server].nil? ? config[:externals][:server] : repo[:server]
          e.reference = repo[:reference] unless repo[:reference].nil?
          e.install_path = File.join directory, repo[:path]
          e.repo.skip_update = options[:skip_external_repo_update]
        end
      end

      @externals
    end

    # Install all externals.
    # @return [Environment] the current environment instance
    def install_externals
      externals.each(&:install)
      self
    end

    # Remove all excludes defined by `config[:excludes]`.
    # @return [Environment] the current environment instance
    def remove_excludes
      return self if config[:excludes].nil?
      config[:excludes].map { |e| Dir["#{directory}/#{e}"] }.flatten.each do |e|
        FileUtils.remove_entry_secure e
      end
      self
    end

    # @return [Array<Assets>] assets with settings and paths loaded from config
    def assets
      return @assets if @assets

      @assets = []

      config[:assets].each do |type, opt|
        next if [:sources].include? type.to_sym
        next if opt[:paths].nil?

        @assets << Assets.new.tap do |a|
          a.type = type
          a.directory = directory
          a.paths = opt[:paths]
          a.options config[:assets][:options].symbolize_keys unless config[:assets][:options].nil?
          a.options opt[:options].symbolize_keys unless opt[:options].nil?
        end
      end unless config[:assets].nil?

      @assets
    end

    # @return [Array] all source files with asset tags
    def sources_with_assets
      return [] if config[:assets].nil?
      return [] if config[:assets][:sources].nil?

      @sources_with_assets = []

      opts = {search_backend: options[:search_backend]}
      [:src_pre, :src_post].each do |opt|
        opts[opt] = config[:assets][:options][opt] unless config[:assets][:options][opt].nil?
      end unless config[:assets][:options].nil?

      config[:assets][:sources].each do |path|
        @sources_with_assets << Assets.find_tags(File.join(directory, path), nil, opts)
      end

      @sources_with_assets.flatten
    end

    # Finds all assets in {#sources_with_assets} and
    # generates the assets and updates the sources.
    # @return [Environment] the current environment instance
    def compile_assets
      sources_with_assets.each do |file|
        source = File.read file
        assets.each { |a| a.update_source! source }
        Utils.write_to_file source, file, preserve: true
      end
      self
    end

    private

    # Checks the config file for invalid settings.
    # @todo Refactor this.
    # - Checks that paths are not absolute or use `../` or `~/`.
    def validate_config
      message = 'bad path in config'

      # Checks the option in the asset key.
      def validate_asset_options(opts)
        opts.each do |k, v|
          fail 'bad option in config' if k == :sprockets_options
          fail message if k == :output && !Utils.safe_path?(v)
        end
      end

      @config[:excludes].each do |v|
        fail message unless Utils.safe_path? v
      end unless @config[:excludes].nil?

      @config[:external].each do |k, v|
        next if k == :server

        v.each do |repo|
          fail message unless Utils.safe_path? repo[1]
        end unless v.nil?
      end unless @config[:external].nil?

      @config[:components].each do |k, v|
        # process @config[:components][:base] then go to the next option
        if k == :base
          fail message unless Utils.safe_path? v
          next
        end unless v.nil?

        # process @config[:components][:paths]
        if k == :paths
          v.each do |path|
            fail message unless Utils.safe_path? path[0]
            fail message unless Utils.safe_path? path[1]
          end
        end
      end unless @config[:components].nil?

      @config[:assets].each do |k, v|
        # process @config[:assets][:options] then go to the next option
        if k == :options
          validate_asset_options v
          next
        end unless v.nil?

        # process @config[:assets][:sources] then go to the next option
        if k == :sources
          v.each_with_index do |source, _|
            fail message unless Utils.safe_path? source
          end
          next
        end

        # process each asset type in @config[:assets]
        v.each do |asset_key, asset_value|
          # process :options
          if asset_key == :options
            validate_asset_options asset_value
            next
          end unless asset_value.nil?

          # process each asset path
          asset_value.each_with_index do |path, _|
            fail message unless Utils.safe_path? path
          end if asset_key == :paths
        end
      end unless @config[:assets].nil?

      @config
    end
  end
end