diff --git a/bin/linux-x64-cwebp b/bin/linux-x64-cwebp old mode 100644 new mode 100755 diff --git a/bin/linux-x86-cwebp b/bin/linux-x86-cwebp old mode 100644 new mode 100755 diff --git a/bin/osx-cwebp b/bin/osx-cwebp old mode 100644 new mode 100755 diff --git a/bin/win-x64-cwebp.exe b/bin/win-x64-cwebp.exe old mode 100644 new mode 100755 diff --git a/bin/win-x86-cwebp.exe b/bin/win-x86-cwebp.exe old mode 100644 new mode 100755 diff --git a/jekyll-webp.gemspec b/jekyll-webp.gemspec index ff76bc6..bd8cdf7 100644 --- a/jekyll-webp.gemspec +++ b/jekyll-webp.gemspec @@ -1,4 +1,5 @@ # coding: utf-8 +require 'date' require_relative 'lib/jekyll-webp/version' Gem::Specification.new do |spec| @@ -23,4 +24,5 @@ Gem::Specification.new do |spec| spec.add_development_dependency "bundler", "~> 1.5" spec.add_development_dependency "rake", "~> 1.5" spec.add_development_dependency "minitest", '~> 5.4', '>= 5.4.3' + spec.add_runtime_dependency "image_size", "~> 2.0" end \ No newline at end of file diff --git a/lib/jekyll-webp.rb b/lib/jekyll-webp.rb index 89bb785..39622b8 100644 --- a/lib/jekyll-webp.rb +++ b/lib/jekyll-webp.rb @@ -3,7 +3,12 @@ require "jekyll-webp/webpExec" require "jekyll-webp/webpGenerator" -module Jekyll +module Jekyll module Webp end # module Webp -end # module Jekyll \ No newline at end of file +end # module Jekyll + +# Register the post-write hook +Jekyll::Hooks.register :site, :post_write do |site| + Jekyll::Webp::WebpProcessor.new(site).process +end \ No newline at end of file diff --git a/lib/jekyll-webp/defaults.rb b/lib/jekyll-webp/defaults.rb index e079e9e..a94b05d 100644 --- a/lib/jekyll-webp/defaults.rb +++ b/lib/jekyll-webp/defaults.rb @@ -13,13 +13,7 @@ module Webp # https://developers.google.com/speed/webp/docs/cwebp#options 'flags' => "-m 4 -pass 4 -af", - # List of directories containing images to optimize, Nested directories only be checked if `nested` is true - 'img_dir' => ["/img"], - - # Whether to search in nested directories or not - 'nested' => false, - - # add ".gif" to the format list to generate webp for animated gifs as well + # File extensions of images to convert to WebP 'formats' => [".jpeg", ".jpg", ".png", ".tiff"], # append .webp to existing extension instead of replacing it @@ -37,13 +31,16 @@ module Webp # Leave as nil to use the cmd line utilities shipped with the gem, override to use your local install 'webp_path' => nil, - # List of files or directories to exclude + # List of files or directories to exclude from WebP generation # e.g. custom or hand generated webp conversion files 'exclude' => [], - # List of files or directories to explicitly include - # e.g. single files outside of the main image directories - 'include' => [] + # Generate thumbnail versions with maximum dimension of 800px (height or width) + 'thumbnails' => false, + + # Number of threads to use for parallel generation. + # 0 or 1 means sequential (no parallelism). + 'threads' => 0 } end # module Webp diff --git a/lib/jekyll-webp/version.rb b/lib/jekyll-webp/version.rb index 230697d..0d94adc 100644 --- a/lib/jekyll-webp/version.rb +++ b/lib/jekyll-webp/version.rb @@ -1,8 +1,8 @@ module Jekyll module Webp - VERSION = "1.0.0" + VERSION = "1.0.1" # When modifying remember to issue a new tag command in git before committing, then push the new tag - # git tag -a v1.0.0 -m "Gem v1.0.0" + # git tag -a v1.0.1 -m "Gem v1.0.1" # git push origin --tags end #module Webp end #module Jekyll diff --git a/lib/jekyll-webp/webpGenerator.rb b/lib/jekyll-webp/webpGenerator.rb index 1e6217f..3647d83 100644 --- a/lib/jekyll-webp/webpGenerator.rb +++ b/lib/jekyll-webp/webpGenerator.rb @@ -1,115 +1,315 @@ -require 'jekyll/document' +require 'nokogiri' require 'fileutils' +require 'pathname' +require 'image_size' module Jekyll module Webp - # - # A static file to hold the generated webp image after generation - # so that Jekyll will copy it into the site output directory - class WebpFile < StaticFile - def write(dest) - true # Recover from strange exception when starting server without --auto - end - end #class WebpFile - - class WebpGenerator < Generator - # This generator is safe from arbitrary code execution. - safe true - - # This generator should be passive with regard to its execution - priority :lowest + class WebpProcessor - # Generate paginated pages if necessary (Default entry point) - # site - The Site. - # - # Returns nothing. - def generate(site) - - # Retrieve and merge the configuration from the site yml file + def initialize(site) + @site = site @config = DEFAULT.merge(site.config['webp'] || {}) + end + def process # If disabled then simply quit if !@config['enabled'] Jekyll.logger.info "WebP:","Disabled in site.config." return end - Jekyll.logger.debug "WebP:","Starting" + Jekyll.logger.debug "WebP:","Starting post-write processing" + + # Find all images referenced in HTML files + referenced_images = scan_html_for_images + + if referenced_images.empty? + Jekyll.logger.info "WebP:","No images found in HTML files" + return + end + + Jekyll.logger.info "WebP:","Found #{referenced_images.length} images to process" + + # Process each referenced image + file_count = process_images(referenced_images) + + # Update HTML files to use WebP images + html_update_count = update_html_files(referenced_images) + + Jekyll.logger.info "WebP:","Post-write processing complete: #{file_count} WebP file(s) generated, #{html_update_count} HTML file(s) updated" + end + + private + + def scan_html_for_images + images = Set.new + + # Find all HTML files in the site destination + Dir.glob(File.join(@site.dest, "**/*.html")).each do |html_file| + next if should_exclude_file?(html_file) + + begin + doc = Nokogiri::HTML(File.read(html_file)) + + # Find all img tags + doc.css('img').each do |img| + src = img['src'] + next unless src + + # Convert relative URLs to absolute paths + image_path = resolve_image_path(src, html_file) + next unless image_path + + # Check if it's a supported format + ext = File.extname(image_path).downcase + if @config['formats'].include?(ext) + images.add(image_path) + end + end + rescue => e + Jekyll.logger.warn "WebP:", "Failed to parse HTML file #{html_file}: #{e.message}" + end + end + + images.to_a + end + + def resolve_image_path(src, html_file) + return nil if src.nil? || src.empty? + + # Handle absolute URLs (http/https) + return nil if src.start_with?('http://', 'https://', '//') + + # Handle data URLs + return nil if src.start_with?('data:') - # If the site destination directory has not yet been created then create it now. Otherwise, we cannot write our file there. - Dir::mkdir(site.dest) if !File.directory? site.dest + # Resolve relative path from HTML file location + html_dir = File.dirname(html_file) + site_dest = @site.dest - # If nesting is enabled, get all the nested directories too - if @config['nested'] - newdir = [] - for imgdir in @config['img_dir'] - # Get every directory below (and including) imgdir, recursively - newdir.concat(Dir.glob(imgdir + "/**/")) + # Make path relative to site destination + if src.start_with?('/') + # Absolute path from site root + resolved_path = File.join(site_dest, src[1..-1]) + else + # Relative path from HTML file + resolved_path = File.expand_path(src, html_dir) + end + + # Ensure the resolved path is within the site destination + return nil unless resolved_path.start_with?(site_dest + '/') + + resolved_path + end + + def should_exclude_file?(file_path) + relative_path = file_path.sub(@site.dest + '/', '') + + @config['exclude'].any? do |exclude_pattern| + if exclude_pattern.end_with?('/') + # Directory pattern + relative_path.start_with?(exclude_pattern) + else + # File pattern + File.fnmatch?(exclude_pattern, relative_path) end - @config['img_dir'] = newdir end + end - # Counting the number of files generated + def process_images(image_paths) file_count = 0 + mutex = Mutex.new + + process_file = Proc.new do |image_path| + next unless File.exist?(image_path) + + # Create output path (same directory as source, but with .webp extension) + ext = File.extname(image_path) + basename = File.basename(image_path, ext) - # Iterate through every image in each of the image folders and create a webp image - # if one has not been created already for that image. - for imgdir in @config['img_dir'] - imgdir_source = File.join(site.source, imgdir) - imgdir_destination = File.join(site.dest, imgdir) - FileUtils::mkdir_p(imgdir_destination) - Jekyll.logger.info "WebP:","Processing #{imgdir_source}" + webp_filename = if @config['append_ext'] + File.basename(image_path) + '.webp' + else + basename + '.webp' + end - # handle only jpg, jpeg, png and gif - for imgfile in Dir[imgdir_source + "**/*.*"] - imgfile_relative_path = File.dirname(imgfile.sub(imgdir_source, "")) + webp_path = File.join(File.dirname(image_path), webp_filename) - # Skip empty stuff - file_ext = File.extname(imgfile).downcase + # Check if regeneration is needed + should_generate = @config['regenerate'] || + !File.exist?(webp_path) || + File.mtime(webp_path) <= File.mtime(image_path) - # If the file is not one of the supported formats, exit early - next if !@config['formats'].include? file_ext + if should_generate + Jekyll.logger.info "WebP:", "Processing #{image_path}" - # TODO: Do an exclude check + # Ensure output directory exists + FileUtils.mkdir_p(File.dirname(webp_path)) - # Create the output file path - outfile_filename = if @config['append_ext'] - File.basename(imgfile) + '.webp' + # Generate WebP + WebpExec.run(@config['quality'], @config['flags'], image_path, webp_path, @config['webp_path']) + + if @config['thumbnails'] + thumbnail_webp_filename = if @config['append_ext'] + File.basename(image_path) + '_thumb.webp' else - file_noext = File.basename(imgfile, file_ext) - file_noext + ".webp" + basename + '_thumb.webp' end - FileUtils::mkdir_p(imgdir_destination + imgfile_relative_path) - outfile_fullpath_webp = File.join(imgdir_destination + imgfile_relative_path, outfile_filename) - - # Check if the file already has a webp alternative? - # If we're force rebuilding all webp files then ignore the check - # also check the modified time on the files to ensure that the webp file - # is newer than the source file, if not then regenerate - if @config['regenerate'] || !File.file?(outfile_fullpath_webp) || - File.mtime(outfile_fullpath_webp) <= File.mtime(imgfile) - Jekyll.logger.info "WebP:", "Change to source image file #{imgfile} detected, regenerating WebP" - - # Generate the file - WebpExec.run(@config['quality'], @config['flags'], imgfile, outfile_fullpath_webp, @config['webp_path']) - file_count += 1 + + thumbnail_webp_path = File.join(File.dirname(image_path), thumbnail_webp_filename) + + begin + image_size = ImageSize.path(image_path) + width = image_size.width + height = image_size.height + + if width && height + # Calculate thumbnail dimensions (max 800px for width or height) + max_dimension = 800.0 + if width > height + new_width = max_dimension + new_height = (height * max_dimension / width).to_i + else + new_height = max_dimension + new_width = (width * max_dimension / height).to_i + end + + # Generate thumbnail WebP with resize flags + resize_flags = "#{@config['flags']} -resize #{new_width} #{new_height}" + WebpExec.run(@config['quality'], resize_flags, image_path, thumbnail_webp_path, @config['thumbnail_webp_path']) + + # Thread-safe increment for thumbnail + if @config['threads'] && @config['threads'] > 1 + mutex.synchronize { file_count += 1 } + else + file_count += 1 + end + end + rescue => e + Jekyll.logger.warn "WebP:", "Failed to generate thumbnail for #{image_path}: #{e.message}" end - if File.file?(outfile_fullpath_webp) - # Keep the webp file from being cleaned by Jekyll - site.static_files << WebpFile.new(site, - site.dest, - File.join(imgdir, imgfile_relative_path), - outfile_filename) + end + + # Thread-safe increment + if @config['threads'] && @config['threads'] > 1 + mutex.synchronize { file_count += 1 } + else + file_count += 1 + end + end + end + + # Execute processing + thread_count = @config['threads'].to_i + + if thread_count > 1 + Jekyll.logger.info "WebP:", "Parallel processing enabled with #{thread_count} threads" + queue = Queue.new + image_paths.each { |path| queue << path } + + workers = (1..thread_count).map do + Thread.new do + begin + while !queue.empty? + # Non-blocking pop; rescue if empty + path = queue.pop(true) rescue nil + process_file.call(path) if path + end + rescue ThreadError + # Queue empty end - end # dir.foreach - end # img_dir + end + end + workers.each(&:join) + else + # Sequential execution + image_paths.each do |path| + process_file.call(path) + end + end + + file_count + end + + def update_html_files(processed_images) + updated_files = 0 + + # Create a mapping of original paths to WebP paths + webp_mapping = {} + processed_images.each do |image_path| + ext = File.extname(image_path) + basename = File.basename(image_path, ext) - Jekyll.logger.info "WebP:","Generator Complete: #{file_count} file(s) generated" + webp_filename = if @config['append_ext'] + File.basename(image_path) + '.webp' + else + basename + '.webp' + end + + webp_path = File.join(File.dirname(image_path), webp_filename) + webp_mapping[image_path] = webp_path + end + + # Process each HTML file + Dir.glob(File.join(@site.dest, "**/*.html")).each do |html_file| + next if should_exclude_file?(html_file) + + begin + content = File.read(html_file) + original_content = content.dup + doc = Nokogiri::HTML(content) + + # Find all img tags + doc.css('img').each do |img| + src = img['src'] + next unless src + + # Resolve the image path + image_path = resolve_image_path(src, html_file) + next unless image_path + + # Check if we have a WebP version for this image + webp_path = webp_mapping[image_path] + next unless webp_path && File.exist?(webp_path) + + # Convert WebP path back to a relative URL from the HTML file + webp_url = make_relative_url(webp_path, html_file, src.start_with?('/')) + + # Update the src attribute + img['src'] = webp_url + end - end #function generate + # Write back if content changed + if doc.to_html != original_content + File.write(html_file, doc.to_html) + updated_files += 1 + Jekyll.logger.debug "WebP:", "Updated #{html_file}" + end + + rescue => e + Jekyll.logger.warn "WebP:", "Failed to update HTML file #{html_file}: #{e.message}" + end + end + + updated_files + end + + def make_relative_url(target_path, html_file, is_absolute) + site_dest = @site.dest + + if is_absolute + # For absolute URLs, make relative to site root + '/' + target_path.sub(site_dest + '/', '') + else + # For relative URLs, calculate relative path from HTML file to target + html_dir = File.dirname(html_file) + Pathname.new(target_path).relative_path_from(Pathname.new(html_dir)).to_s + end + end - end #class WebPGenerator + end # class WebpProcessor - end #module Webp -end #module Jekyll + end # module Webp +end # module Jekyll