jekyll-auto-thumbnails 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE +661 -0
- data/README.md +123 -0
- data/lib/jekyll-auto-thumbnails/configuration.rb +51 -0
- data/lib/jekyll-auto-thumbnails/digest_calculator.rb +19 -0
- data/lib/jekyll-auto-thumbnails/generator.rb +132 -0
- data/lib/jekyll-auto-thumbnails/hooks.rb +145 -0
- data/lib/jekyll-auto-thumbnails/registry.rb +56 -0
- data/lib/jekyll-auto-thumbnails/scanner.rb +152 -0
- data/lib/jekyll-auto-thumbnails/url_resolver.rb +48 -0
- data/lib/jekyll-auto-thumbnails/version.rb +5 -0
- data/lib/jekyll-auto-thumbnails.rb +17 -0
- metadata +179 -0
data/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Automatic Image Thumbnails for Jekyll
|
|
2
|
+
|
|
3
|
+
Scans your rendered HTML for local images with `width` or `height` attributes, then automatically generates and uses appropriately-sized thumbnails for them, if the `src` image is bigger than that.
|
|
4
|
+
|
|
5
|
+
Can also take global maximum dimensions (such as for fixed-width layouts) and thumbnail images that don't have explicit size attributes, too.
|
|
6
|
+
|
|
7
|
+
Requires [ImageMagick](https://imagemagick.org) to be installed.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Add to your `Gemfile`:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
group :jekyll_plugins do
|
|
15
|
+
gem "jekyll-auto-thumbnails"
|
|
16
|
+
end
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Run:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bundle install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**System Requirement**: ImageMagick must be installed (gem requires the `convert` and `identify` commands to be available).
|
|
26
|
+
|
|
27
|
+
## Configuration
|
|
28
|
+
|
|
29
|
+
```yaml
|
|
30
|
+
# _config.yml
|
|
31
|
+
auto_thumbnails:
|
|
32
|
+
enabled: true # default: true
|
|
33
|
+
|
|
34
|
+
# Maximum dimensions for automatic thumbnailing
|
|
35
|
+
# Images exceeding these get thumbnails even without explicit sizing
|
|
36
|
+
max_width: 1200 # pixels (optional)
|
|
37
|
+
max_height: 800 # pixels (optional)
|
|
38
|
+
|
|
39
|
+
# JPEG quality for generated thumbnails (0-100)
|
|
40
|
+
quality: 85 # default: 85
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## How It Works
|
|
44
|
+
|
|
45
|
+
1. **HTML Scanning**: After all plugins run, scans `<article>` tags for images
|
|
46
|
+
2. **Size Detection**:
|
|
47
|
+
- Images with `width` or `height` attributes → uses those dimensions
|
|
48
|
+
- Unsized images exceeding max config → thumbnails to max dimensions
|
|
49
|
+
3. **Generation**: Creates thumbnails with MD5-based caching in `.jekyll-cache/`
|
|
50
|
+
4. **URL Replacement**: Updates `<img src>` to point to thumbnails
|
|
51
|
+
5. **File Copying**: Copies thumbnails to `_site/` after build
|
|
52
|
+
|
|
53
|
+
## Usage Examples
|
|
54
|
+
|
|
55
|
+
### Explicit Sizing
|
|
56
|
+
|
|
57
|
+
```html
|
|
58
|
+
<article>
|
|
59
|
+
<img src="/photo.jpg" width="300" height="200">
|
|
60
|
+
<!-- Generates: photo_thumb-abc123-300x200.jpg -->
|
|
61
|
+
</article>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Automatic Optimization
|
|
65
|
+
|
|
66
|
+
```yaml
|
|
67
|
+
# _config.yml
|
|
68
|
+
auto_thumbnails:
|
|
69
|
+
max_width: 800
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```html
|
|
73
|
+
<article>
|
|
74
|
+
<img src="/big-photo.jpg">
|
|
75
|
+
<!-- If photo is 2000x1500, generates: big-photo_thumb-def456-800x600.jpg -->
|
|
76
|
+
</article>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### With Markdown
|
|
80
|
+
|
|
81
|
+
Works automatically with any Markdown processor, because it checks the rendered HTML!
|
|
82
|
+
|
|
83
|
+
```markdown
|
|
84
|
+
 <!-- Auto-detects size -->
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
## Cache Behavior
|
|
89
|
+
|
|
90
|
+
- First build: Generates thumbnails, stores in `.jekyll-cache/`
|
|
91
|
+
- Subsequent builds: Reuses cached thumbnails (fast!)
|
|
92
|
+
- Source changed: MD5 mismatch detected, regenerates automatically
|
|
93
|
+
- Dimensions changed: Different filename, generates new thumbnail
|
|
94
|
+
|
|
95
|
+
## Troubleshooting
|
|
96
|
+
|
|
97
|
+
### ImageMagick Not Found
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
# Ubuntu/Debian
|
|
101
|
+
sudo apt-get install imagemagick
|
|
102
|
+
|
|
103
|
+
# macOS
|
|
104
|
+
brew install imagemagick
|
|
105
|
+
|
|
106
|
+
# Verify installation
|
|
107
|
+
which convert identify
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Thumbnails Not Generating
|
|
111
|
+
|
|
112
|
+
Check build output for warnings:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
bundle exec jekyll build --verbose
|
|
116
|
+
# Look for "AutoThumbnails:" messages
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Clear Cache
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
rm -rf .jekyll-cache/jekyll-auto-thumbnails/
|
|
123
|
+
```
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JekyllAutoThumbnails
|
|
4
|
+
# Configuration parser for auto_thumbnails settings
|
|
5
|
+
#
|
|
6
|
+
# Parses Jekyll site config for auto_thumbnails options and provides
|
|
7
|
+
# accessor methods with appropriate defaults and validation.
|
|
8
|
+
class Configuration
|
|
9
|
+
attr_reader :max_width, :max_height, :quality, :cache_dir
|
|
10
|
+
|
|
11
|
+
# Initialize configuration from Jekyll site
|
|
12
|
+
#
|
|
13
|
+
# @param site [Jekyll::Site] The Jekyll site object
|
|
14
|
+
def initialize(site)
|
|
15
|
+
config_hash = site.config["auto_thumbnails"] || {}
|
|
16
|
+
|
|
17
|
+
@enabled = config_hash.fetch("enabled", true)
|
|
18
|
+
@max_width = parse_dimension(config_hash["max_width"])
|
|
19
|
+
@max_height = parse_dimension(config_hash["max_height"])
|
|
20
|
+
@quality = parse_quality(config_hash.fetch("quality", 85))
|
|
21
|
+
@cache_dir = File.join(site.source, ".jekyll-cache", "jekyll-auto-thumbnails")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Check if image optimization is enabled
|
|
25
|
+
#
|
|
26
|
+
# @return [Boolean] true if enabled (default: true)
|
|
27
|
+
def enabled?
|
|
28
|
+
@enabled
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# Parse dimension value (max_width or max_height)
|
|
34
|
+
#
|
|
35
|
+
# @param value [Object] dimension value from config
|
|
36
|
+
# @return [Integer, nil] positive integer or nil
|
|
37
|
+
def parse_dimension(value)
|
|
38
|
+
val = value.to_i
|
|
39
|
+
val.positive? ? val : nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Parse quality value (0-100)
|
|
43
|
+
#
|
|
44
|
+
# @param value [Object] quality value from config
|
|
45
|
+
# @return [Integer] quality value 0-100, or 85 if invalid
|
|
46
|
+
def parse_quality(value)
|
|
47
|
+
val = value.to_i
|
|
48
|
+
val.between?(0, 100) ? val : 85
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/md5"
|
|
4
|
+
|
|
5
|
+
module JekyllAutoThumbnails
|
|
6
|
+
# MD5 digest calculation for cache keys
|
|
7
|
+
#
|
|
8
|
+
# Computes short MD5 digests of image files for use in thumbnail filenames.
|
|
9
|
+
module DigestCalculator
|
|
10
|
+
# Compute short (6-char) MD5 digest of file
|
|
11
|
+
#
|
|
12
|
+
# @param file_path [String] path to file
|
|
13
|
+
# @return [String] first 6 characters of MD5 hex digest
|
|
14
|
+
# @raise [Errno::ENOENT] if file doesn't exist
|
|
15
|
+
def self.short_digest(file_path)
|
|
16
|
+
Digest::MD5.file(file_path).hexdigest[0...6]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module JekyllAutoThumbnails
|
|
6
|
+
# Thumbnail generation via ImageMagick
|
|
7
|
+
class Generator
|
|
8
|
+
# Initialize generator
|
|
9
|
+
#
|
|
10
|
+
# @param config [Configuration] configuration
|
|
11
|
+
# @param site_source [String] Jekyll site source directory
|
|
12
|
+
def initialize(config, site_source)
|
|
13
|
+
@config = config
|
|
14
|
+
@site_source = site_source
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Check if ImageMagick is available (cross-platform)
|
|
18
|
+
#
|
|
19
|
+
# @return [Boolean] true if convert command found in PATH
|
|
20
|
+
def imagemagick_available?
|
|
21
|
+
cmd_name = Gem.win_platform? ? "convert.exe" : "convert"
|
|
22
|
+
path_dirs = ENV["PATH"].to_s.split(File::PATH_SEPARATOR)
|
|
23
|
+
|
|
24
|
+
path_dirs.any? do |dir|
|
|
25
|
+
executable = File.join(dir, cmd_name)
|
|
26
|
+
File.executable?(executable)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Generate thumbnail (with caching)
|
|
31
|
+
#
|
|
32
|
+
# @param url [String] image URL
|
|
33
|
+
# @param width [Integer, nil] target width
|
|
34
|
+
# @param height [Integer, nil] target height
|
|
35
|
+
# @return [String, nil] path to cached thumbnail or nil if failed
|
|
36
|
+
def generate(url, width, height)
|
|
37
|
+
# Resolve source file
|
|
38
|
+
source_path = UrlResolver.to_filesystem_path(url, @site_source)
|
|
39
|
+
return nil unless source_path && File.exist?(source_path)
|
|
40
|
+
|
|
41
|
+
# Compute digest
|
|
42
|
+
digest = DigestCalculator.short_digest(source_path)
|
|
43
|
+
|
|
44
|
+
# Build thumbnail filename
|
|
45
|
+
basename = File.basename(source_path, File.extname(source_path))
|
|
46
|
+
ext = File.extname(source_path)
|
|
47
|
+
thumb_filename = build_thumbnail_filename(basename, digest, width, height, ext)
|
|
48
|
+
|
|
49
|
+
# Check cache
|
|
50
|
+
cached_path = File.join(@config.cache_dir, thumb_filename)
|
|
51
|
+
return cached_path if File.exist?(cached_path)
|
|
52
|
+
|
|
53
|
+
# Generate
|
|
54
|
+
FileUtils.mkdir_p(@config.cache_dir)
|
|
55
|
+
success = shell_generate(source_path, cached_path, width, height)
|
|
56
|
+
|
|
57
|
+
return nil unless success
|
|
58
|
+
|
|
59
|
+
# Check if thumbnail is larger than original
|
|
60
|
+
if File.size(cached_path) > File.size(source_path)
|
|
61
|
+
Jekyll.logger.warn "AutoThumbnails:",
|
|
62
|
+
"Thumbnail larger than original (#{File.size(cached_path)} > #{File.size(source_path)}), " \
|
|
63
|
+
"deleting #{cached_path}"
|
|
64
|
+
FileUtils.rm_f(cached_path)
|
|
65
|
+
return nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
cached_path
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Build thumbnail filename
|
|
72
|
+
#
|
|
73
|
+
# @param basename [String] image basename (no extension)
|
|
74
|
+
# @param digest [String] 6-char MD5 digest
|
|
75
|
+
# @param width [Integer, nil] width
|
|
76
|
+
# @param height [Integer, nil] height
|
|
77
|
+
# @param ext [String] file extension
|
|
78
|
+
# @return [String] thumbnail filename
|
|
79
|
+
def build_thumbnail_filename(basename, digest, width, height, ext)
|
|
80
|
+
width_str = width || ""
|
|
81
|
+
height_str = height || ""
|
|
82
|
+
"#{basename}_thumb-#{digest}-#{width_str}x#{height_str}#{ext}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# Generate thumbnail using ImageMagick
|
|
88
|
+
#
|
|
89
|
+
# @param source_path [String] source image path
|
|
90
|
+
# @param dest_path [String] destination thumbnail path
|
|
91
|
+
# @param width [Integer, nil] target width
|
|
92
|
+
# @param height [Integer, nil] target height
|
|
93
|
+
# @return [Boolean] true if successful
|
|
94
|
+
def shell_generate(source_path, dest_path, width, height)
|
|
95
|
+
geometry = build_geometry(width, height)
|
|
96
|
+
ext = File.extname(source_path)
|
|
97
|
+
|
|
98
|
+
# Build command array (no shell interpretation)
|
|
99
|
+
cmd = ["convert", source_path, "-resize", geometry]
|
|
100
|
+
|
|
101
|
+
# Add quality for lossy formats
|
|
102
|
+
if quality_needed?(ext)
|
|
103
|
+
cmd << "-quality"
|
|
104
|
+
cmd << @config.quality.to_s
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
cmd << dest_path
|
|
108
|
+
|
|
109
|
+
# Call system with array (bypasses shell)
|
|
110
|
+
system(*cmd)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Build ImageMagick geometry string
|
|
114
|
+
#
|
|
115
|
+
# @param width [Integer, nil] target width
|
|
116
|
+
# @param height [Integer, nil] target height
|
|
117
|
+
# @return [String] geometry string (e.g., "400x300>")
|
|
118
|
+
def build_geometry(width, height)
|
|
119
|
+
width_str = width || ""
|
|
120
|
+
height_str = height || ""
|
|
121
|
+
"#{width_str}x#{height_str}>"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Check if quality parameter needed for image format
|
|
125
|
+
#
|
|
126
|
+
# @param ext [String] file extension
|
|
127
|
+
# @return [Boolean] true if quality parameter should be used
|
|
128
|
+
def quality_needed?(ext)
|
|
129
|
+
%w[.jpg .jpeg].include?(ext.downcase)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JekyllAutoThumbnails
|
|
4
|
+
# Jekyll hook integration
|
|
5
|
+
module Hooks
|
|
6
|
+
# Initialize optimization system
|
|
7
|
+
#
|
|
8
|
+
# @param site [Jekyll::Site] Jekyll site
|
|
9
|
+
def self.initialize_system(site)
|
|
10
|
+
config = Configuration.new(site)
|
|
11
|
+
return unless config.enabled?
|
|
12
|
+
|
|
13
|
+
site.data["auto_thumbnails_config"] = config
|
|
14
|
+
site.data["auto_thumbnails_registry"] = Registry.new
|
|
15
|
+
site.data["auto_thumbnails_generator"] = Generator.new(config, site.source)
|
|
16
|
+
|
|
17
|
+
Jekyll.logger.info "AutoThumbnails:", "System initialized"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Process site - scan, generate, replace
|
|
21
|
+
#
|
|
22
|
+
# @param site [Jekyll::Site] Jekyll site
|
|
23
|
+
def self.process_site(site)
|
|
24
|
+
config = site.data["auto_thumbnails_config"]
|
|
25
|
+
return unless config&.enabled?
|
|
26
|
+
|
|
27
|
+
registry = site.data["auto_thumbnails_registry"]
|
|
28
|
+
generator = site.data["auto_thumbnails_generator"]
|
|
29
|
+
|
|
30
|
+
# Check ImageMagick
|
|
31
|
+
unless generator.imagemagick_available?
|
|
32
|
+
Jekyll.logger.warn "AutoThumbnails:", "ImageMagick not found - skipping"
|
|
33
|
+
return
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Scan all documents and pages
|
|
37
|
+
(site.documents + site.pages).each do |doc|
|
|
38
|
+
next unless doc.output
|
|
39
|
+
|
|
40
|
+
Scanner.scan_html(doc.output, registry, config, site.source)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
Jekyll.logger.info "AutoThumbnails:", "Found #{registry.entries.size} images to optimize"
|
|
44
|
+
|
|
45
|
+
# Generate thumbnails
|
|
46
|
+
url_map = {}
|
|
47
|
+
registry.entries.each do |url, requirements|
|
|
48
|
+
cached_path = generator.generate(url, requirements[:width], requirements[:height])
|
|
49
|
+
|
|
50
|
+
if cached_path
|
|
51
|
+
# Build thumbnail URL (use forward slashes for URLs, not File.join)
|
|
52
|
+
thumb_filename = File.basename(cached_path)
|
|
53
|
+
url_dir = File.dirname(url)
|
|
54
|
+
# Ensure URL uses forward slashes (cross-platform URLs)
|
|
55
|
+
thumb_url = if url_dir == "."
|
|
56
|
+
"/#{thumb_filename}"
|
|
57
|
+
else
|
|
58
|
+
"#{url_dir}/#{thumb_filename}"
|
|
59
|
+
end
|
|
60
|
+
url_map[url] = thumb_url
|
|
61
|
+
else
|
|
62
|
+
Jekyll.logger.warn "AutoThumbnails:", "Failed to generate thumbnail for #{url}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Store url_map for post_write hook
|
|
67
|
+
site.data["auto_thumbnails_url_map"] = url_map
|
|
68
|
+
|
|
69
|
+
# Replace URLs in HTML
|
|
70
|
+
(site.documents + site.pages).each do |doc|
|
|
71
|
+
next unless doc.output
|
|
72
|
+
|
|
73
|
+
doc.output = replace_urls(doc.output, url_map)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
Jekyll.logger.info "AutoThumbnails:", "Generated #{url_map.size} thumbnails"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Copy thumbnails from cache to _site
|
|
80
|
+
#
|
|
81
|
+
# @param site [Jekyll::Site] Jekyll site
|
|
82
|
+
def self.copy_thumbnails(site)
|
|
83
|
+
config = site.data["auto_thumbnails_config"]
|
|
84
|
+
return unless config&.enabled?
|
|
85
|
+
|
|
86
|
+
url_map = site.data["auto_thumbnails_url_map"]
|
|
87
|
+
return unless url_map && !url_map.empty?
|
|
88
|
+
|
|
89
|
+
Jekyll.logger.info "AutoThumbnails:", "Copying #{url_map.size} thumbnails to _site"
|
|
90
|
+
|
|
91
|
+
url_map.each_value do |thumb_url|
|
|
92
|
+
thumb_filename = File.basename(thumb_url)
|
|
93
|
+
cached_path = File.join(config.cache_dir, thumb_filename)
|
|
94
|
+
|
|
95
|
+
# Build destination path in _site preserving directory structure
|
|
96
|
+
dest_path = File.join(site.dest, thumb_url.sub(%r{^/}, ""))
|
|
97
|
+
dest_dir = File.dirname(dest_path)
|
|
98
|
+
|
|
99
|
+
FileUtils.mkdir_p(dest_dir)
|
|
100
|
+
FileUtils.cp(cached_path, dest_path)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
Jekyll.logger.info "AutoThumbnails:", "All thumbnails copied"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Replace image URLs in HTML
|
|
107
|
+
#
|
|
108
|
+
# @param html [String] HTML content
|
|
109
|
+
# @param url_map [Hash] original URL => thumbnail URL
|
|
110
|
+
# @return [String] modified HTML
|
|
111
|
+
def self.replace_urls(html, url_map)
|
|
112
|
+
# Return early if no replacements needed
|
|
113
|
+
return html if url_map.empty?
|
|
114
|
+
|
|
115
|
+
doc = Nokogiri::HTML(html)
|
|
116
|
+
|
|
117
|
+
doc.css("article img").each do |img|
|
|
118
|
+
src = img["src"]
|
|
119
|
+
next unless src
|
|
120
|
+
|
|
121
|
+
# Find thumbnail URL for this image
|
|
122
|
+
thumb_url = url_map[src]
|
|
123
|
+
img["src"] = thumb_url if thumb_url
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Serialize with encoding declaration to match Jekyll output
|
|
127
|
+
doc.to_html
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private_class_method :replace_urls
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Register Jekyll hooks
|
|
135
|
+
Jekyll::Hooks.register :site, :post_read do |site|
|
|
136
|
+
JekyllAutoThumbnails::Hooks.initialize_system(site)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
Jekyll::Hooks.register :site, :post_render do |site|
|
|
140
|
+
JekyllAutoThumbnails::Hooks.process_site(site)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
Jekyll::Hooks.register :site, :post_write do |site|
|
|
144
|
+
JekyllAutoThumbnails::Hooks.copy_thumbnails(site)
|
|
145
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JekyllAutoThumbnails
|
|
4
|
+
# Image requirement registry
|
|
5
|
+
#
|
|
6
|
+
# Tracks images needing thumbnails and their required dimensions.
|
|
7
|
+
# Handles duplicate registrations by keeping the largest dimensions.
|
|
8
|
+
class Registry
|
|
9
|
+
# Initialize empty registry
|
|
10
|
+
def initialize
|
|
11
|
+
@entries = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Register an image with required dimensions
|
|
15
|
+
#
|
|
16
|
+
# @param url [String] image URL
|
|
17
|
+
# @param width [Integer, nil] required width
|
|
18
|
+
# @param height [Integer, nil] required height
|
|
19
|
+
def register(url, width, height)
|
|
20
|
+
existing = @entries[url]
|
|
21
|
+
|
|
22
|
+
@entries[url] = if existing
|
|
23
|
+
# Update to max dimensions
|
|
24
|
+
{
|
|
25
|
+
width: [existing[:width], width].compact.max,
|
|
26
|
+
height: [existing[:height], height].compact.max
|
|
27
|
+
}
|
|
28
|
+
else
|
|
29
|
+
{ width: width, height: height }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Check if image is registered
|
|
34
|
+
#
|
|
35
|
+
# @param url [String] image URL
|
|
36
|
+
# @return [Boolean] true if registered
|
|
37
|
+
def registered?(url)
|
|
38
|
+
@entries.key?(url)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Get requirements for image
|
|
42
|
+
#
|
|
43
|
+
# @param url [String] image URL
|
|
44
|
+
# @return [Hash, nil] {width:, height:} or nil if not registered
|
|
45
|
+
def requirements_for(url)
|
|
46
|
+
@entries[url]&.dup
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Get all registered entries
|
|
50
|
+
#
|
|
51
|
+
# @return [Hash] url => {width:, height:}
|
|
52
|
+
def entries
|
|
53
|
+
@entries.dup
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module JekyllAutoThumbnails
|
|
7
|
+
# HTML scanning for images
|
|
8
|
+
module Scanner
|
|
9
|
+
# Scan HTML for images needing optimization
|
|
10
|
+
#
|
|
11
|
+
# @param html [String] HTML content
|
|
12
|
+
# @param registry [Registry] image registry
|
|
13
|
+
# @param config [Configuration] configuration
|
|
14
|
+
# @param site_source [String] Jekyll site source (optional, for unsized image checking)
|
|
15
|
+
def self.scan_html(html, registry, config, site_source = nil)
|
|
16
|
+
doc = Nokogiri::HTML(html)
|
|
17
|
+
|
|
18
|
+
# Only process images in <article> tags
|
|
19
|
+
doc.css("article img").each do |img|
|
|
20
|
+
src = img["src"]
|
|
21
|
+
next unless src
|
|
22
|
+
next if UrlResolver.external?(src)
|
|
23
|
+
|
|
24
|
+
# Check for explicit dimensions
|
|
25
|
+
width = parse_dimension(img["width"])
|
|
26
|
+
height = parse_dimension(img["height"])
|
|
27
|
+
|
|
28
|
+
if width || height
|
|
29
|
+
# Explicitly sized - calculate missing dimension if needed
|
|
30
|
+
if site_source && (width.nil? || height.nil?)
|
|
31
|
+
Jekyll.logger.debug "AutoThumbnails:", "Calculating dimensions for #{src} (#{width}x#{height})"
|
|
32
|
+
width, height = calculate_dimensions(src, width, height, site_source)
|
|
33
|
+
Jekyll.logger.debug "AutoThumbnails:", "Calculated: #{width}x#{height}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Skip if dimensions match original (no thumbnail needed)
|
|
37
|
+
if site_source && dimensions_match_original?(src, width, height, site_source)
|
|
38
|
+
Jekyll.logger.debug "AutoThumbnails:", "Skipping #{src} - dimensions match original"
|
|
39
|
+
next
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Jekyll.logger.debug "AutoThumbnails:", "Registering #{src} at #{width}x#{height}"
|
|
43
|
+
registry.register(src, width, height)
|
|
44
|
+
elsif site_source && (config.max_width || config.max_height)
|
|
45
|
+
# Unsized but max config exists - check actual dimensions
|
|
46
|
+
check_and_register_oversized(src, registry, config, site_source)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Check if image exceeds max dimensions and register if so
|
|
52
|
+
#
|
|
53
|
+
# @param url [String] image URL
|
|
54
|
+
# @param registry [Registry] image registry
|
|
55
|
+
# @param config [Configuration] configuration
|
|
56
|
+
# @param site_source [String] site source directory
|
|
57
|
+
def self.check_and_register_oversized(url, registry, config, site_source)
|
|
58
|
+
file_path = UrlResolver.to_filesystem_path(url, site_source)
|
|
59
|
+
return unless file_path && File.exist?(file_path)
|
|
60
|
+
|
|
61
|
+
actual_width, actual_height = image_dimensions(file_path)
|
|
62
|
+
return unless actual_width && actual_height
|
|
63
|
+
|
|
64
|
+
# Check if exceeds max dimensions
|
|
65
|
+
exceeds_width = config.max_width && actual_width > config.max_width
|
|
66
|
+
exceeds_height = config.max_height && actual_height > config.max_height
|
|
67
|
+
|
|
68
|
+
return unless exceeds_width || exceeds_height
|
|
69
|
+
|
|
70
|
+
# Register with max dimensions (preserving aspect ratio logic in Generator)
|
|
71
|
+
registry.register(url, config.max_width, config.max_height)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Get image dimensions from file
|
|
75
|
+
#
|
|
76
|
+
# @param file_path [String] path to image file
|
|
77
|
+
# @return [Array<Integer, Integer>, nil] [width, height] or nil
|
|
78
|
+
def self.image_dimensions(file_path)
|
|
79
|
+
# Use ImageMagick identify command (shell-free, cross-platform)
|
|
80
|
+
# Use [0] to get only first frame (important for animated GIFs)
|
|
81
|
+
output, status = Open3.capture2e("identify", "-format", "%wx%h", "#{file_path}[0]")
|
|
82
|
+
return nil unless status.success? && !output.strip.empty?
|
|
83
|
+
|
|
84
|
+
width, height = output.strip.split("x").map(&:to_i)
|
|
85
|
+
[width, height]
|
|
86
|
+
rescue StandardError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Calculate missing dimension based on aspect ratio
|
|
91
|
+
#
|
|
92
|
+
# @param url [String] image URL
|
|
93
|
+
# @param width [Integer, nil] specified width
|
|
94
|
+
# @param height [Integer, nil] specified height
|
|
95
|
+
# @param site_source [String] site source directory
|
|
96
|
+
# @return [Array<Integer, Integer>] [width, height] with calculated dimension
|
|
97
|
+
def self.calculate_dimensions(url, width, height, site_source)
|
|
98
|
+
file_path = UrlResolver.to_filesystem_path(url, site_source)
|
|
99
|
+
return [width, height] unless file_path && File.exist?(file_path)
|
|
100
|
+
|
|
101
|
+
actual_width, actual_height = image_dimensions(file_path)
|
|
102
|
+
return [width, height] unless actual_width && actual_height
|
|
103
|
+
|
|
104
|
+
# Calculate missing dimension preserving aspect ratio
|
|
105
|
+
if width && !height
|
|
106
|
+
# Width specified, calculate height
|
|
107
|
+
aspect_ratio = actual_height.to_f / actual_width
|
|
108
|
+
height = (width * aspect_ratio).round
|
|
109
|
+
elsif height && !width
|
|
110
|
+
# Height specified, calculate width
|
|
111
|
+
aspect_ratio = actual_width.to_f / actual_height
|
|
112
|
+
width = (height * aspect_ratio).round
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
[width, height]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Parse dimension attribute (width or height)
|
|
119
|
+
#
|
|
120
|
+
# @param value [String, nil] attribute value
|
|
121
|
+
# @return [Integer, nil] parsed integer or nil
|
|
122
|
+
def self.parse_dimension(value)
|
|
123
|
+
return nil if value.nil? || value.empty?
|
|
124
|
+
|
|
125
|
+
# Strip non-numeric characters (e.g., "300px" -> 300)
|
|
126
|
+
numeric = value.to_s.gsub(/[^\d]/, "")
|
|
127
|
+
return nil if numeric.empty?
|
|
128
|
+
|
|
129
|
+
numeric.to_i
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Check if requested dimensions match original dimensions
|
|
133
|
+
#
|
|
134
|
+
# @param url [String] image URL
|
|
135
|
+
# @param width [Integer] requested width
|
|
136
|
+
# @param height [Integer] requested height
|
|
137
|
+
# @param site_source [String] site source directory
|
|
138
|
+
# @return [Boolean] true if dimensions match original
|
|
139
|
+
def self.dimensions_match_original?(url, width, height, site_source)
|
|
140
|
+
file_path = UrlResolver.to_filesystem_path(url, site_source)
|
|
141
|
+
return false unless file_path && File.exist?(file_path)
|
|
142
|
+
|
|
143
|
+
actual_width, actual_height = image_dimensions(file_path)
|
|
144
|
+
return false unless actual_width && actual_height
|
|
145
|
+
|
|
146
|
+
width == actual_width && height == actual_height
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private_class_method :check_and_register_oversized, :calculate_dimensions, :parse_dimension,
|
|
150
|
+
:dimensions_match_original?
|
|
151
|
+
end
|
|
152
|
+
end
|