diff --git a/lib/prism/assets/compiler.rb b/lib/prism/assets/compiler.rb new file mode 100644 index 0000000..8a1d6d5 --- /dev/null +++ b/lib/prism/assets/compiler.rb @@ -0,0 +1,68 @@ +require 'fileutils' + +module Prism + module Assets + # Sprockets/Propshaft-lite precompile step: walks every file under + # `source`, content-addresses it (sha256, first 8 hex chars) into + # `name-digest.ext` under `output`, and writes a manifest.json mapping + # the original relative path ("css/application.css") to the + # fingerprinted one ("css/application-3f2a9c1e.css"). Re-running with + # unchanged file content reproduces the exact same fingerprinted name + # (pure function of the bytes, no timestamps involved), and any + # fingerprinted file left over from a PREVIOUS compile that no logical + # path points to anymore is deleted - the same cleanup + # `rake assets:clean` does in Rails, done automatically on every compile + # instead of as a separate step. + class Compiler + DIGEST_LENGTH = 8 + + def initialize(source:, output:) + @source = File.expand_path(source) + @output = File.expand_path(output) + end + + def compile! + FileUtils.mkdir_p(@output) + previous = Manifest.load(manifest_path).to_h.values + manifest = Manifest.new + + each_source_file do |absolute, logical_path| + fingerprinted = fingerprint(logical_path, absolute) + destination = File.join(@output, fingerprinted) + FileUtils.mkdir_p(File.dirname(destination)) + FileUtils.cp(absolute, destination) + manifest[logical_path] = fingerprinted + end + + stale = previous - manifest.to_h.values + stale.each { |path| FileUtils.rm_f(File.join(@output, path)) } + + manifest.save(manifest_path) + manifest + end + + def manifest_path + File.join(@output, 'manifest.json') + end + + private + + def each_source_file + return unless File.directory?(@source) + + Dir.glob(File.join(@source, '**', '*')).sort.each do |absolute| + next unless File.file?(absolute) + + yield absolute, absolute.delete_prefix("#{@source}/") + end + end + + def fingerprint(logical_path, absolute) + digest = Digest::SHA256.file(absolute).hexdigest[0, DIGEST_LENGTH] + ext = File.extname(logical_path) + base = logical_path.delete_suffix(ext) + "#{base}-#{digest}#{ext}" + end + end + end +end diff --git a/lib/prism/assets/manifest.rb b/lib/prism/assets/manifest.rb new file mode 100644 index 0000000..b3ffdaf --- /dev/null +++ b/lib/prism/assets/manifest.rb @@ -0,0 +1,50 @@ +module Prism + module Assets + # Maps logical asset paths ("application.css") to fingerprinted paths + # ("application-3f2a9c1e.css") the way Sprockets/Propshaft's + # manifest.json does. Purely a lookup table plus JSON (de)serialization - + # Compiler is what actually populates one. + class Manifest + AssetNotFound = Class.new(StandardError) + + def initialize(assets = {}) + @assets = assets.transform_keys(&:to_s) + end + + attr_reader :assets + + def []=(logical_path, fingerprinted_path) + @assets[logical_path.to_s] = fingerprinted_path + end + + # Raises when the manifest is authoritative (production-style, + # precompiled ahead of time) so a typo in a view never silently links + # to a 404 - the same tradeoff Rails makes with + # config.assets.unknown_asset_fallback = false. + def lookup(logical_path) + @assets.fetch(logical_path.to_s) do + raise AssetNotFound, "asset #{logical_path.inspect} is not in the manifest " \ + "(known: #{@assets.keys.sort.join(', ')})" + end + end + + def key?(logical_path) + @assets.key?(logical_path.to_s) + end + + def to_h + @assets.dup + end + + def save(path) + File.write(path, JSON.pretty_generate(@assets)) + end + + def self.load(path) + return new unless File.file?(path) + + new(JSON.parse(File.read(path))) + end + end + end +end