jekyll-imgflow 0.1.6 → 0.1.8

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a8e1eb8739d53b1ebcdbc440d4c77279ce895794ffb1beda8de9df159227d91
4
- data.tar.gz: 730145b3e3017a1a63281f7e68c518cdc82cce708e55d942cdabdba002900d7c
3
+ metadata.gz: '096d8389110e5128cb043db130059fed14d477e859068074645aa2d915453d78'
4
+ data.tar.gz: 47808ceb1562c5445bb3d6b6acbca1477a19ab9477e414c712bce676c8dd579e
5
5
  SHA512:
6
- metadata.gz: 23a45cd5749d4c389b65ffdbd54ca3240465441a325861d739acfedcaf342bd3e111eb04165b925d790f8b862a79d40c9bcbe85979181b184ea7bc3253065e00
7
- data.tar.gz: 9b71032b277287766586518ef618cde9aa0d787f2ed3e7b061b609685f68221a46a7d5a09b53e8a4907512bf11611028a9b8add638f37477b08b607e9f38aa89
6
+ metadata.gz: 6a22f23719023088512647af702af94e220f7697016cac7ee85d1d894e49be7e2f35c67439e1e6701d14d3a1be20d5cc52cf1961d9dd64da39ae06431b868994
7
+ data.tar.gz: e25a2fb82d17e77791776935adfe333cd2f127396702902988b3956b10d41e4a4e3101ec9a50a09aaf44a876c7baa42c32135c37b1fdb785179a946c2472c793
data/README.md CHANGED
@@ -5,6 +5,7 @@ A [Jekyll](https://jekyllrb.com/) plugin for automatic image optimization with m
5
5
  [![DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/gundestrup/jekyll-imgflow)
6
6
  [![Status](https://img.shields.io/badge/status-active-success)](https://github.com/gundestrup/jekyll-imgflow)
7
7
  [![codecov](https://codecov.io/gh/gundestrup/jekyll-imgflow/branch/main/graph/badge.svg)](https://codecov.io/gh/gundestrup/jekyll-imgflow)
8
+ [![VS Code Extension](https://img.shields.io/badge/VS%20Code%20Companion-jekyll--imgflow-blue?logo=visual-studio-code)](https://github.com/gundestrup/jekyll-imgflow-vscode)
8
9
  [![License](https://img.shields.io/github/license/gundestrup/jekyll-imgflow)](LICENSE)
9
10
 
10
11
  ## 🚀 Quick Start
@@ -16,17 +17,28 @@ gem 'jekyll-imgflow'
16
17
  # Install
17
18
  bundle install
18
19
 
19
- # Configure (_config.yml)
20
+ # Configure (_config.yml) — only originals and output are required,
21
+ # everything else has sensible defaults:
20
22
  imgflow:
21
23
  originals: "assets/images/originals"
22
24
  output: "assets/images/optimized"
23
- sizes: {sm: 400, md: 800, lg: 1200}
24
- formats: [webp, avif, jpg]
25
25
 
26
26
  # Use in templates
27
27
  {% imgflow photo.jpg resize width:800 %}
28
28
  ```
29
29
 
30
+ > **Full configuration options:** `quality`, `backend_priority`, `formats`,
31
+ > `fallback_format`, `sizes`, provider URLs, and more — see the
32
+ > [Installation Guide](docs/installation.md#4-add-to-_configyml)
33
+ > and [Configuration Reference](docs/ARCHITECTURE.md).
34
+
35
+ > **Note:** Image references require exact filenames (or paths relative to
36
+ > `originals`). There is no fuzzy matching during the build yet — if you type
37
+ > `photo.jpg` but the file is `photo.jpeg`, the build will fail with an error.
38
+ >
39
+ > For editor autocomplete while writing `{% imgflow %}` tags, install the
40
+ > [VS Code companion extension](https://github.com/gundestrup/jekyll-imgflow-vscode).
41
+
30
42
  ## ✨ Key Features
31
43
 
32
44
  - **Multiple Providers**: [Sharp](https://github.com/lovell/sharp), [ImageMagick](https://github.com/ImageMagick/ImageMagick), [LibVips](https://github.com/libvips/libvips), [Imgproxy](https://github.com/imgproxy/imgproxy), [Weserv](https://github.com/weserv/images), [Flyimg](https://github.com/flyimg/flyimg)
@@ -112,18 +124,17 @@ gem 'jekyll-imgflow'
112
124
  # Install
113
125
  bundle install
114
126
 
115
- # Configure (_config.yml)
127
+ # Configure (_config.yml) — only originals and output required:
116
128
  imgflow:
117
129
  originals: "assets/images/originals"
118
130
  output: "assets/images/optimized"
119
- sizes: {sm: 400, md: 800, lg: 1200}
120
- formats: [webp, avif, jpg]
121
131
 
122
132
  # Use in templates
123
133
  {% imgflow photo.jpg resize width:800 %}
124
134
  ```
125
135
 
126
- **See:** [installation.md](docs/installation.md) for detailed installation instructions and configuration options
136
+ **See:** [installation.md](docs/installation.md) for detailed installation and
137
+ [all configuration options](docs/installation.md#4-add-to-_configyml)
127
138
 
128
139
  ## 🐳 Docker Setup (Recommended)
129
140
 
@@ -213,8 +213,13 @@ module JekyllImgFlow
213
213
  # Use FilenameGenerator to generate proper filename
214
214
  operations = { width: width, format: format, quality: config.quality }
215
215
  filename = filename_generator.generate_filename(original_name, operations)
216
- # Write to _site during build (after Jekyll copies assets)
217
- output_path = path_resolver.resolve_output_path(filename)
216
+
217
+ # Preserve original directory structure under output
218
+ subdir = File.dirname(original_name)
219
+ subdir = nil if subdir == "."
220
+
221
+ # Write to source directory so Jekyll copies files to _site during write phase
222
+ output_path = path_resolver.resolve_source_output_path(filename, subdir)
218
223
 
219
224
  tasks << {
220
225
  original_name: original_name,
@@ -42,12 +42,14 @@ module JekyllImgFlow
42
42
  original_paths = find_original_images
43
43
  Jekyll.logger.info "📸 Found #{original_paths.length} original images"
44
44
 
45
+ originals_dir = File.join(@site.source, @config.originals)
46
+
45
47
  # Build tasks for each original image
46
48
  original_paths.each do |path|
47
- original_name = File.basename(path)
49
+ original_name = path.sub("#{originals_dir}/", "")
48
50
 
49
51
  # Check if needs processing (file changed or provider changed)
50
- if needs_processing?(path)
52
+ if needs_processing?(original_name, path)
51
53
  Jekyll.logger.info "🔧 Queuing default versions for: #{original_name}"
52
54
 
53
55
  # Build default tasks
@@ -84,8 +86,10 @@ module JekyllImgFlow
84
86
  provider_name = @registry.current_provider.class.name.split("::").last.downcase
85
87
 
86
88
  # Convert absolute path to relative for manifest storage
89
+ # Default versions are written to the source directory so Jekyll can
90
+ # copy them to _site during the write phase.
87
91
  absolute_path = task[:result]
88
- relative_path = absolute_path.sub(@site.dest, "")
92
+ relative_path = absolute_path.sub(@site.source, "")
89
93
 
90
94
  # Register the version in manifest
91
95
  @manifest.register_version(
@@ -113,10 +117,13 @@ module JekyllImgFlow
113
117
  end
114
118
 
115
119
  # Check if image needs processing
116
- # @param path [String] Path to original image
120
+ # @param original_name [String] Path to original image relative to originals directory
121
+ # @param path [String, nil] Full path to original image (defaults to original_name)
117
122
  # @return [Boolean] True if processing needed
118
- def needs_processing?(path)
119
- original_name = File.basename(path)
123
+ def needs_processing?(original_name, path = nil)
124
+ path ||= original_name
125
+ # For single-argument calls, derive a flat original_name from the full path
126
+ original_name = File.basename(original_name) if original_name == path
120
127
 
121
128
  # If no versions exist, needs processing
122
129
  return true unless @manifest.versions?(original_name)
@@ -2,10 +2,29 @@
2
2
 
3
3
  module JekyllImgFlow
4
4
  class Config
5
+ # Sensible defaults — users only need to configure `originals` and `output`
6
+ # (or even nothing at all). All other values fall back to these.
7
+ DEFAULT_ORIGINALS = "assets/images/originals"
8
+ DEFAULT_OUTPUT = "assets/images/optimized"
9
+ DEFAULT_INPUT_FORMATS = %w[jpg jpeg png webp avif gif tiff tif svg].freeze
10
+ # Output format priority: avif > webp > png > jpg (fallback).
11
+ # The browser picks the first <source> format it supports, so avif is
12
+ # served to modern browsers, webp as a fallback for avif, png for
13
+ # transparency, and jpg as the universal <img> fallback.
14
+ DEFAULT_FORMATS = %w[avif webp png jpg].freeze
15
+ DEFAULT_SIZES = { "sm" => 400, "md" => 800, "lg" => 1200, "xl" => 2000 }.freeze
16
+ DEFAULT_QUALITY = 85
17
+ # Ordered by speed (see docs/providers.md benchmark):
18
+ # sharp (14s) → libvips (22s) → imagemagick (31s) → imgproxy (31s) → weserv (30s) → flyimg
19
+ DEFAULT_BACKEND_PRIORITY = %w[sharp libvips imagemagick imgproxy weserv flyimg].freeze
20
+ # Format used for the <img> fallback in <picture> elements.
21
+ # All other formats in `formats` get <source> tags for browsers that support them.
22
+ DEFAULT_FALLBACK_FORMAT = "jpg"
23
+
5
24
  attr_reader :site, :originals, :output, :input_formats, :sizes, :formats,
6
25
  :quality, :backend_priority, :imgproxy_url,
7
26
  :image_compressor_url, :weserv_url, :flyimg_url,
8
- :optimize_qualities
27
+ :optimize_qualities, :fallback_format
9
28
 
10
29
  def initialize(site)
11
30
  shared = site.config["shared_images_configs"] || {}
@@ -13,20 +32,14 @@ module JekyllImgFlow
13
32
  cfg = shared.merge(overrides)
14
33
 
15
34
  @site = site
16
- @originals = cfg["originals"]
17
- @output = cfg["output"]
18
- @input_formats = cfg["input_formats"]
19
- @sizes = cfg["sizes"]
20
- @formats = cfg["formats"]
21
- @quality = cfg["quality"]
22
- @backend_priority = cfg["backend_priority"]
23
-
24
- # Validate required config fields
25
- raise ArgumentError, "No originals configured in unified config" unless @originals
26
- raise ArgumentError, "No output configured in unified config" unless @output
27
- raise ArgumentError, "No input_formats configured in unified config" unless @input_formats
28
- raise ArgumentError, "No formats configured in unified config" unless @formats
29
- raise ArgumentError, "No sizes configured in unified config" unless @sizes
35
+ @originals = cfg["originals"] || DEFAULT_ORIGINALS
36
+ @output = cfg["output"] || DEFAULT_OUTPUT
37
+ @input_formats = cfg["input_formats"] || DEFAULT_INPUT_FORMATS
38
+ @sizes = cfg["sizes"] || DEFAULT_SIZES
39
+ @formats = cfg["formats"] || DEFAULT_FORMATS
40
+ @quality = cfg["quality"] || DEFAULT_QUALITY
41
+ @backend_priority = cfg["backend_priority"] || DEFAULT_BACKEND_PRIORITY
42
+ @fallback_format = cfg["fallback_format"] || DEFAULT_FALLBACK_FORMAT
30
43
 
31
44
  @imgproxy_url = cfg["imgproxy_url"]
32
45
  @image_compressor_url = cfg["image_compressor_url"]
@@ -58,13 +58,13 @@ module JekyllImgFlow
58
58
 
59
59
  private
60
60
 
61
- # Get fallback formats from config or use sensible defaults
61
+ # Get fallback formats only the configured fallback format (e.g. jpg).
62
+ # These are skipped in the <source> loop and used for the <img> fallback.
63
+ # All other formats (webp, avif, etc.) get <source> tags.
62
64
  def fallback_formats
63
- if @config&.formats
64
- # Convert format names to extensions and add dots
65
- @config.formats.map { |fmt| ".#{fmt}" }
65
+ if @config&.fallback_format
66
+ [".#{@config.fallback_format}"]
66
67
  else
67
- # Fallback to common formats if no config available
68
68
  %w[.jpg .jpeg .png]
69
69
  end
70
70
  end
@@ -242,13 +242,40 @@ module JekyllImgFlow
242
242
  "<div#{parent_attrs}>#{html}</div>"
243
243
  end
244
244
 
245
- # Group results by file extension/format
245
+ # Group results by file extension, ordered by config format priority
246
+ # (avif → webp → png → jpg). The browser picks the first <source> format
247
+ # it supports, so the order of <source> tags determines format priority.
246
248
  def group_by_format(results)
247
- results.group_by { |r| File.extname(r).downcase }
249
+ grouped = results.group_by { |r| File.extname(r).downcase }
250
+ sort_format_groups(grouped)
248
251
  end
249
252
 
250
- # Find fallback image (prefer jpg/jpeg/png)
253
+ # Sort format groups by config format priority (first = highest priority).
254
+ # Formats not in config.formats are appended at the end alphabetically.
255
+ def sort_format_groups(grouped)
256
+ priority = build_format_priority
257
+ grouped.sort_by { |ext, _| priority.index(ext) || Float::INFINITY }
258
+ end
259
+
260
+ # Build a lookup of format extensions ordered by config.formats priority.
261
+ # E.g. ["avif", "webp", "png", "jpg"] → [".avif", ".webp", ".png", ".jpg"]
262
+ def build_format_priority
263
+ return %w[.avif .webp .png .jpg] unless @config&.formats
264
+
265
+ @config.formats.map { |fmt| ".#{fmt.downcase}" }
266
+ end
267
+
268
+ # Find fallback image — prefer the configured fallback format,
269
+ # then any legacy format (jpg/jpeg/png), then first result.
251
270
  def find_fallback_image(results)
271
+ return results.first if results.empty?
272
+
273
+ preferred = @config&.fallback_format
274
+ if preferred
275
+ found = results.find { |r| r.end_with?(".#{preferred}") }
276
+ return found if found
277
+ end
278
+
252
279
  results.find { |r| r.match?(/\.(jpe?g|png)$/i) } || results.first
253
280
  end
254
281
 
@@ -64,7 +64,15 @@ module Jekyll
64
64
 
65
65
  site = context.registers[:site]
66
66
  page = context.registers[:page]
67
- original_name = File.basename(parsed[:image_path])
67
+
68
+ # Resolve input path
69
+ input_path = resolve_image_path(parsed[:image_path], site, components[:config])
70
+ raise ArgumentError, "Input image file not found: #{input_path}" unless File.file?(input_path)
71
+
72
+ # Store original name relative to the configured originals directory
73
+ # so output can mirror the original directory structure
74
+ originals_dir = File.join(site.source, components[:config].originals)
75
+ original_name = input_path.sub("#{originals_dir}/", "")
68
76
 
69
77
  # Get page path for manifest tracking
70
78
  # Try multiple attributes to get the page identifier
@@ -74,10 +82,6 @@ module Jekyll
74
82
  "unknown"
75
83
  end
76
84
 
77
- # Resolve input path
78
- input_path = resolve_image_path(parsed[:image_path], site, components[:config])
79
- raise ArgumentError, "Input image file not found: #{input_path}" unless File.file?(input_path)
80
-
81
85
  # Process operations using clean architecture
82
86
  operations = parsed[:operations]
83
87
 
@@ -96,8 +100,12 @@ module Jekyll
96
100
  params[:quality] ||= components[:config].quality # Use default quality
97
101
  end
98
102
 
103
+ # Preserve original directory structure under output
104
+ subdir = File.dirname(original_name)
105
+ subdir = nil if subdir == "."
106
+
99
107
  filename = components[:filename_generator].generate_filename(input_path, params)
100
- output_path = components[:path_resolver].resolve_output_path(filename)
108
+ output_path = components[:path_resolver].resolve_source_output_path(filename, subdir)
101
109
 
102
110
  # Determine version type
103
111
  version_type = determine_version_type(params, components[:config])
@@ -199,11 +207,12 @@ module Jekyll
199
207
  path_resolver = JekyllImgFlow::PathResolver.new(config)
200
208
 
201
209
  # Handle different path formats
202
- if image_path.start_with?("/") || image_path.include?("/")
203
- # Absolute or relative path
210
+ if image_path.start_with?("/")
211
+ # Absolute site-root path: /assets/images/originals/valdemar/photo.jpg
204
212
  File.join(site.source, image_path)
205
213
  else
206
- # Use PathResolver to get CLI path (full filesystem path)
214
+ # Relative path: photo.jpg or valdemar/photo.jpg
215
+ # Always resolve against the configured originals directory
207
216
  path_resolver.cli_path(image_path)
208
217
  end
209
218
  end
@@ -114,7 +114,7 @@ module JekyllImgFlow
114
114
 
115
115
  # Get optimized directory path
116
116
  config = JekyllImgFlow::Config.new(@site)
117
- optimized_dir = File.join(@site.dest, config.output)
117
+ optimized_dir = File.join(@site.source, config.output)
118
118
 
119
119
  # Safety guard: prevent deletion of _site itself
120
120
  raise "Refusing to delete optimized directory: path resolves to site dest or is empty" if optimized_dir == @site.dest || optimized_dir.empty?
@@ -261,12 +261,7 @@ module JekyllImgFlow
261
261
  cleaned = []
262
262
 
263
263
  orphans.each do |orphan|
264
- output_file = if orphan["output"].start_with?("/")
265
- orphan["output"]
266
- else
267
- File.join(@site.dest,
268
- orphan["output"])
269
- end
264
+ output_file = File.join(@site.source, orphan["output"])
270
265
  if File.exist?(output_file)
271
266
  File.delete(output_file)
272
267
  cleaned << orphan["output"]
@@ -42,13 +42,18 @@ module JekyllImgFlow
42
42
  type = operation[:type]
43
43
  params = operation[:params]
44
44
 
45
+ # Determine version type
46
+ version_type = determine_version_type(params)
47
+
48
+ # Preserve original directory structure under output
49
+ subdir = File.dirname(original_name)
50
+ subdir = nil if subdir == "."
51
+
45
52
  # Generate filename using FilenameGenerator (JPT compatible)
46
53
  filename = @filename_generator.generate_filename(input_path, params)
47
- # Write to _site during build (after Jekyll copies assets)
48
- actual_output_path = @path_resolver.resolve_output_path(filename)
49
54
 
50
- # Determine version type
51
- version_type = determine_version_type(params)
55
+ # Write to source directory so Jekyll copies files to _site during the write phase
56
+ actual_output_path = @path_resolver.resolve_source_output_path(filename, subdir)
52
57
 
53
58
  # Ensure output directory exists before processing
54
59
  FileUtils.mkdir_p(File.dirname(actual_output_path))
@@ -68,8 +73,8 @@ module JekyllImgFlow
68
73
 
69
74
  # Register in manifest
70
75
  if @manifest
71
- # Convert absolute path to relative for manifest storage
72
- relative_path = actual_output_path.sub(@path_resolver.site_dest, "")
76
+ # Store relative path (with leading /) for manifest storage
77
+ relative_path = "/#{@path_resolver.resolve_relative_output_path(filename, subdir)}"
73
78
 
74
79
  provider_name = @provider.class.provider_name
75
80
  @manifest.register_version(
@@ -83,9 +88,43 @@ module JekyllImgFlow
83
88
  )
84
89
  end
85
90
 
91
+ # Register as Jekyll static file so Jekyll copies it to _site during
92
+ # the write phase. Without this, files created during pre_render (or
93
+ # during render via imgflow tags) are not picked up by Jekyll's static
94
+ # file reader, which runs before pre_render. This would leave _site
95
+ # without optimized images until a second build.
96
+ register_jekyll_static_file(actual_output_path)
97
+
86
98
  actual_output_path
87
99
  end
88
100
 
101
+ # Register a generated file as a Jekyll::StaticFile so Jekyll copies it
102
+ # to _site during the write phase. No-op for mock/test sites.
103
+ # @param file_path [String] Absolute path to the generated file in source
104
+ def register_jekyll_static_file(file_path)
105
+ site = @config&.site
106
+ return unless site.is_a?(Jekyll::Site)
107
+
108
+ relative = file_path.delete_prefix("#{site.source}/")
109
+ return if relative == file_path # not under site source
110
+
111
+ add_static_file(site, relative) unless static_file_registered?(site, relative)
112
+ end
113
+
114
+ # @param site [Jekyll::Site] Jekyll site object
115
+ # @param relative [String] Path relative to site source
116
+ def static_file_registered?(site, relative)
117
+ site.static_files.any? { |sf| sf.relative_path == "/#{relative}" }
118
+ end
119
+
120
+ # @param site [Jekyll::Site] Jekyll site object
121
+ # @param relative [String] Path relative to site source
122
+ def add_static_file(site, relative)
123
+ site.static_files << Jekyll::StaticFile.new(
124
+ site, site.source, File.dirname(relative), File.basename(relative)
125
+ )
126
+ end
127
+
89
128
  # Process multiple operations on an image in sequence (batch)
90
129
  # @param operations [Array<Hash>] Array of operations to process
91
130
  # @param input_path [String] Path to input image
@@ -61,26 +61,29 @@ module JekyllImgFlow
61
61
 
62
62
  # Resolve output path for generated filename
63
63
  # @param filename [String] Generated filename
64
+ # @param subdir [String, nil] Optional subdirectory under output
64
65
  # @return [String] Full path to output file in _site (for specialized versions during rendering)
65
- def resolve_output_path(filename)
66
+ def resolve_output_path(filename, subdir = nil)
66
67
  # Resolve output path relative to site destination
67
- File.join(@config.site.dest, @config.output, filename)
68
+ File.join([@config.site.dest, @config.output, subdir, filename].compact)
68
69
  end
69
70
 
70
71
  # Resolve output path to source directory for default images
71
72
  # @param filename [String] Generated filename
73
+ # @param subdir [String, nil] Optional subdirectory under output
72
74
  # @return [String] Full path to output file in source (for default versions during pre_render)
73
- def resolve_source_output_path(filename)
75
+ def resolve_source_output_path(filename, subdir = nil)
74
76
  # Default images go to source directory so Jekyll can copy them to _site
75
- File.join(@config.site.source, @config.output, filename)
77
+ File.join([@config.site.source, @config.output, subdir, filename].compact)
76
78
  end
77
79
 
78
80
  # Resolve relative output path for manifest storage
79
81
  # @param filename [String] Generated filename
82
+ # @param subdir [String, nil] Optional subdirectory under output
80
83
  # @return [String] Relative path from site root
81
- def resolve_relative_output_path(filename)
84
+ def resolve_relative_output_path(filename, subdir = nil)
82
85
  # Return path relative to site root (without leading /)
83
- File.join(@config.output, filename)
86
+ File.join([@config.output, subdir, filename].compact)
84
87
  end
85
88
 
86
89
  # Get site destination directory
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JekyllImgFlow
4
- VERSION = "0.1.6"
4
+ VERSION = "0.1.8"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-imgflow
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svend Gundestrup