asset.rb 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. require 'time'
  2. require 'set'
  3. module Sprockets
  4. # `Asset` is the base class for `BundledAsset` and `StaticAsset`.
  5. class Asset
  6. # Internal initializer to load `Asset` from serialized `Hash`.
  7. def self.from_hash(environment, hash)
  8. return unless hash.is_a?(Hash)
  9. klass = case hash['class']
  10. when 'BundledAsset'
  11. BundledAsset
  12. when 'ProcessedAsset'
  13. ProcessedAsset
  14. when 'StaticAsset'
  15. StaticAsset
  16. else
  17. nil
  18. end
  19. if klass
  20. asset = klass.allocate
  21. asset.init_with(environment, hash)
  22. asset
  23. end
  24. rescue UnserializeError
  25. nil
  26. end
  27. attr_reader :logical_path, :pathname
  28. attr_reader :content_type, :mtime, :length, :digest
  29. def initialize(environment, logical_path, pathname)
  30. @root = environment.root
  31. @logical_path = logical_path.to_s
  32. @pathname = Pathname.new(pathname)
  33. @content_type = environment.content_type_of(pathname)
  34. @mtime = environment.stat(pathname).mtime
  35. @length = environment.stat(pathname).size
  36. @digest = environment.file_digest(pathname).hexdigest
  37. end
  38. # Initialize `Asset` from serialized `Hash`.
  39. def init_with(environment, coder)
  40. @root = environment.root
  41. @logical_path = coder['logical_path']
  42. @content_type = coder['content_type']
  43. @digest = coder['digest']
  44. if pathname = coder['pathname']
  45. # Expand `$root` placeholder and wrapper string in a `Pathname`
  46. @pathname = Pathname.new(expand_root_path(pathname))
  47. end
  48. if mtime = coder['mtime']
  49. # Parse time string
  50. @mtime = Time.parse(mtime)
  51. end
  52. if length = coder['length']
  53. # Convert length to an `Integer`
  54. @length = Integer(length)
  55. end
  56. end
  57. # Copy serialized attributes to the coder object
  58. def encode_with(coder)
  59. coder['class'] = self.class.name.sub(/Sprockets::/, '')
  60. coder['logical_path'] = logical_path
  61. coder['pathname'] = relativize_root_path(pathname).to_s
  62. coder['content_type'] = content_type
  63. coder['mtime'] = mtime.iso8601
  64. coder['length'] = length
  65. coder['digest'] = digest
  66. end
  67. # Return logical path with digest spliced in.
  68. #
  69. # "foo/bar-37b51d194a7513e45b56f6524f2d51f2.js"
  70. #
  71. def digest_path
  72. logical_path.sub(/\.(\w+)$/) { |ext| "-#{digest}#{ext}" }
  73. end
  74. # Return an `Array` of `Asset` files that are declared dependencies.
  75. def dependencies
  76. []
  77. end
  78. # Expand asset into an `Array` of parts.
  79. #
  80. # Appending all of an assets body parts together should give you
  81. # the asset's contents as a whole.
  82. #
  83. # This allows you to link to individual files for debugging
  84. # purposes.
  85. def to_a
  86. [self]
  87. end
  88. # `body` is aliased to source by default if it can't have any dependencies.
  89. def body
  90. source
  91. end
  92. # Return `String` of concatenated source.
  93. def to_s
  94. source
  95. end
  96. # Add enumerator to allow `Asset` instances to be used as Rack
  97. # compatible body objects.
  98. def each
  99. yield to_s
  100. end
  101. # Checks if Asset is fresh by comparing the actual mtime and
  102. # digest to the inmemory model.
  103. #
  104. # Used to test if cached models need to be rebuilt.
  105. def fresh?(environment)
  106. # Check current mtime and digest
  107. dependency_fresh?(environment, self)
  108. end
  109. # Checks if Asset is stale by comparing the actual mtime and
  110. # digest to the inmemory model.
  111. #
  112. # Subclass must override `fresh?` or `stale?`.
  113. def stale?(environment)
  114. !fresh?(environment)
  115. end
  116. # Save asset to disk.
  117. def write_to(filename, options = {})
  118. # Gzip contents if filename has '.gz'
  119. options[:compress] ||= File.extname(filename) == '.gz'
  120. File.open("#{filename}+", 'wb') do |f|
  121. if options[:compress]
  122. # Run contents through `Zlib`
  123. gz = Zlib::GzipWriter.new(f, Zlib::BEST_COMPRESSION)
  124. gz.write to_s
  125. gz.close
  126. else
  127. # Write out as is
  128. f.write to_s
  129. f.close
  130. end
  131. end
  132. # Atomic write
  133. FileUtils.mv("#{filename}+", filename)
  134. # Set mtime correctly
  135. File.utime(mtime, mtime, filename)
  136. nil
  137. ensure
  138. # Ensure tmp file gets cleaned up
  139. FileUtils.rm("#{filename}+") if File.exist?("#{filename}+")
  140. end
  141. # Pretty inspect
  142. def inspect
  143. "#<#{self.class}:0x#{object_id.to_s(16)} " +
  144. "pathname=#{pathname.to_s.inspect}, " +
  145. "mtime=#{mtime.inspect}, " +
  146. "digest=#{digest.inspect}" +
  147. ">"
  148. end
  149. def hash
  150. digest.hash
  151. end
  152. # Assets are equal if they share the same path, mtime and digest.
  153. def eql?(other)
  154. other.class == self.class &&
  155. other.logical_path == self.logical_path &&
  156. other.mtime.to_i == self.mtime.to_i &&
  157. other.digest == self.digest
  158. end
  159. alias_method :==, :eql?
  160. protected
  161. # Internal: String paths that are marked as dependencies after processing.
  162. #
  163. # Default to an empty `Array`.
  164. def dependency_paths
  165. @dependency_paths ||= []
  166. end
  167. # Internal: `ProccessedAsset`s that are required after processing.
  168. #
  169. # Default to an empty `Array`.
  170. def required_assets
  171. @required_assets ||= []
  172. end
  173. # Get pathname with its root stripped.
  174. def relative_pathname
  175. @relative_pathname ||= Pathname.new(relativize_root_path(pathname))
  176. end
  177. # Replace `$root` placeholder with actual environment root.
  178. def expand_root_path(path)
  179. path.to_s.sub(/^\$root/, @root)
  180. end
  181. # Replace actual environment root with `$root` placeholder.
  182. def relativize_root_path(path)
  183. path.to_s.sub(/^#{Regexp.escape(@root)}/, '$root')
  184. end
  185. # Check if dependency is fresh.
  186. #
  187. # `dep` is a `Hash` with `path`, `mtime` and `hexdigest` keys.
  188. #
  189. # A `Hash` is used rather than other `Asset` object because we
  190. # want to test non-asset files and directories.
  191. def dependency_fresh?(environment, dep)
  192. path, mtime, hexdigest = dep.pathname.to_s, dep.mtime, dep.digest
  193. stat = environment.stat(path)
  194. # If path no longer exists, its definitely stale.
  195. if stat.nil?
  196. return false
  197. end
  198. # Compare dependency mime to the actual mtime. If the
  199. # dependency mtime is newer than the actual mtime, the file
  200. # hasn't changed since we created this `Asset` instance.
  201. #
  202. # However, if the mtime is newer it doesn't mean the asset is
  203. # stale. Many deployment environments may recopy or recheckout
  204. # assets on each deploy. In this case the mtime would be the
  205. # time of deploy rather than modified time.
  206. if mtime >= stat.mtime
  207. return true
  208. end
  209. digest = environment.file_digest(path)
  210. # If the mtime is newer, do a full digest comparsion. Return
  211. # fresh if the digests match.
  212. if hexdigest == digest.hexdigest
  213. return true
  214. end
  215. # Otherwise, its stale.
  216. false
  217. end
  218. end
  219. end