jekyll-imgflow 0.1.10 → 0.3.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +22 -13
  3. data/lib/jekyll-imgflow/batch_manager.rb +10 -7
  4. data/lib/jekyll-imgflow/build_time_processor.rb +70 -61
  5. data/lib/jekyll-imgflow/config.rb +6 -7
  6. data/lib/jekyll-imgflow/filename_generator.rb +8 -10
  7. data/lib/jekyll-imgflow/generated_file_cleaner.rb +81 -0
  8. data/lib/jekyll-imgflow/hooks.rb +19 -5
  9. data/lib/jekyll-imgflow/html_generator.rb +22 -20
  10. data/lib/jekyll-imgflow/imgflow_tag.rb +86 -70
  11. data/lib/jekyll-imgflow/manifest_manager.rb +132 -53
  12. data/lib/jekyll-imgflow/modal_assets.rb +107 -0
  13. data/lib/jekyll-imgflow/operation_processor.rb +43 -22
  14. data/lib/jekyll-imgflow/parser.rb +1 -1
  15. data/lib/jekyll-imgflow/picture_tag_adaptor.rb +1 -1
  16. data/lib/jekyll-imgflow/preset_manager.rb +58 -7
  17. data/lib/jekyll-imgflow/presets/gallery.yml +7 -0
  18. data/lib/jekyll-imgflow/presets/hero.yml +7 -0
  19. data/lib/jekyll-imgflow/presets/thumbnail.yml +7 -0
  20. data/lib/jekyll-imgflow/processing_stats.rb +80 -0
  21. data/lib/jekyll-imgflow/provider_registry.rb +18 -3
  22. data/lib/jekyll-imgflow/providers/base_provider.rb +12 -1
  23. data/lib/jekyll-imgflow/providers/flyimg.rb +2 -1
  24. data/lib/jekyll-imgflow/providers/imagemagick.rb +72 -3
  25. data/lib/jekyll-imgflow/providers/imgproxy.rb +2 -1
  26. data/lib/jekyll-imgflow/providers/libvips.rb +152 -100
  27. data/lib/jekyll-imgflow/providers/sharp.rb +2 -1
  28. data/lib/jekyll-imgflow/providers/weserv.rb +3 -2
  29. data/lib/jekyll-imgflow/tags/opacity_tag.rb +8 -1
  30. data/lib/jekyll-imgflow/tags/resize_tag.rb +2 -0
  31. data/lib/jekyll-imgflow/tags/watermark_tag.rb +8 -1
  32. data/lib/jekyll-imgflow/tasks.rb +129 -0
  33. data/lib/jekyll-imgflow/version.rb +1 -1
  34. data/lib/jekyll-imgflow.rb +3 -0
  35. metadata +25 -157
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 94373ec599b8e291a5a76cb072d27db634329d5a2c345a42d2bbade48860a143
4
- data.tar.gz: 45c949cf624fafb3c290898ccca514677d1b336514fb8cb06dc9009a0936faac
3
+ metadata.gz: d37c208e7250fe6da8291bfab933d5df7391347d0ce95492e32e1d94e54df7a8
4
+ data.tar.gz: cd222c0e6dbdcffe168ec0919a12d53c57324a277cb9efeecb5173a98c4c5b04
5
5
  SHA512:
6
- metadata.gz: 3715974b3af0d82d439e1b96a0d80650ae360a142c61898845b1ed6dda1a6fdbc53a6648f2ca6150df82d40d1a7f123bd4a84524657947638cf1a076df45a3c0
7
- data.tar.gz: 1eeed12f791b156bfc87e4a3479845cae4e975a4067ebe9530f6cc87e6b1f3e23a48ddb7b27e892bbb3441bcfd1be83aed0bc998438ecf647be5dfc345d8ae1a
6
+ metadata.gz: 2f566b0e2d70993a209a5353cf1b91d54e3b3f3143397146f58bbbcf19428eab9797ed5def60efdd3d8f926f214fa2ec6670d47ccfd6c000fe2f9c35b2bac00c
7
+ data.tar.gz: 0b601aea8c16d71133816f034b4277e399e6108569af169c0984416ef6e5d90edc30490d17af254f736f2897eae5c6b55d515888a4b71af35dd8020b63f37a88
data/README.md CHANGED
@@ -29,10 +29,9 @@ imgflow:
29
29
  {% imgflow photo.jpg resize width:800 %}
30
30
  ```
31
31
 
32
- > **Full configuration options:** `quality`, `backend_priority`, `formats`,
33
- > `fallback_format`, `sizes`, provider URLs, and more see the
34
- > [Installation Guide](docs/installation.md#4-add-to-_configyml)
35
- > and [Configuration Reference](docs/ARCHITECTURE.md).
32
+ > **Configuration:** Only `originals` and `output` are required in `_config.yml`
33
+ > everything else has sensible defaults. See the
34
+ > [Installation Guide](docs/installation.md#4-add-to-_configyml) for full options.
36
35
 
37
36
  > **Note:** Image references require exact filenames (or paths relative to
38
37
  > `originals`). There is no fuzzy matching during the build yet — if you type
@@ -86,12 +85,12 @@ ImgFlow has two processing flows: **Build-Time** (pre-generation) and **Runtime*
86
85
  ## 🎯 Provider Support
87
86
 
88
87
  ### HTTP API Services (Recommended)
89
- - **[Imgproxy](https://github.com/imgproxy/imgproxy)** - Fast, reliable (Port 33001)
90
- - **[Weserv](https://github.com/weserv/images)** - Battle-tested (Port 33007)
91
- - **[Flyimg](https://github.com/flyimg/flyimg)** - On-the-fly processing (Port 33008)
88
+ - **[Imgproxy](https://github.com/imgproxy/imgproxy)** - Fast, reliable (Port 4022)
89
+ - **[Weserv](https://github.com/weserv/images)** - Battle-tested (Port 4026)
90
+ - **[Flyimg](https://github.com/flyimg/flyimg)** - On-the-fly processing (Port 4030)
92
91
 
93
92
  ### CLI Tools (Local)
94
- - **[Sharp](https://github.com/lovell/sharp)** - Fastest (Node.js/libvips)
93
+ - **[Sharp](https://github.com/lovell/sharp)** - Node.js/libvips processing
95
94
  - **[ImageMagick](https://github.com/ImageMagick/ImageMagick)** - Feature-rich
96
95
  - **[LibVips](https://github.com/libvips/libvips)** - Memory efficient
97
96
 
@@ -110,7 +109,7 @@ rake test # All tests
110
109
  rake parallel:test # Parallel testing (faster)
111
110
 
112
111
  # Docker Services
113
- docker-compose -f docker-compose.test.yml up -d # Start services
112
+ rake start_services # Start services (pulls latest pinned images)
114
113
  rake check_services # Verify services
115
114
  ```
116
115
 
@@ -130,25 +129,35 @@ bundle install
130
129
  imgflow:
131
130
  originals: "assets/images/originals"
132
131
  output: "assets/images/optimized"
132
+ # cache_dir defaults to ".cache/imgflow" (stores manifest, survives _site wipes)
133
133
 
134
134
  # Use in templates
135
135
  {% imgflow photo.jpg resize width:800 %}
136
+
137
+ # Or use built-in presets:
138
+ {% imgflow photo.jpg preset:gallery %}
136
139
  ```
137
140
 
141
+ **Built-in presets:** `gallery` (400px, avif/webp/jpg, Q80), `hero` (800px, avif/webp/jpg, Q85), `thumbnail` (150px, webp/jpg, Q75). They work without installation. To copy editable versions, add `require "jekyll-imgflow/tasks"` to the site's `Rakefile`, then run `rake imgflow:install_presets`.
142
+
138
143
  **See:** [installation.md](docs/installation.md) for detailed installation and
139
- [all configuration options](docs/installation.md#4-add-to-_configyml)
144
+ [all configuration options](docs/installation.md#4-add-to-_configyml),
145
+ [presets.md](docs/usage/presets.md) for the preset system
140
146
 
141
147
  ## 🐳 Docker Setup (Recommended)
142
148
 
143
149
  ```bash
144
- # Start all services
145
- docker-compose -f docker-compose.test.yml --env-file .env.test up -d
150
+ # Start all services (pulls latest pinned images first)
151
+ rake start_services
146
152
 
147
153
  # Check services
148
154
  rake check_services
149
155
 
156
+ # Check if pinned images are outdated (run before releases)
157
+ rake check_docker_images
158
+
150
159
  # Stop services
151
- docker-compose -f docker-compose.test.yml down
160
+ rake stop_services
152
161
  ```
153
162
 
154
163
  **See:** [docker.md](docs/docker.md) for detailed Docker configuration and services
@@ -91,11 +91,12 @@ module JekyllImgFlow
91
91
  def process_task(task)
92
92
  # Check if processing is needed
93
93
  # Check if output is up-to-date
94
- if task[:skip_if_exists] && File.exist?(task[:output_path]) && !@operation_processor.needs_processing?(
95
- task[:input_path],
96
- task[:output_path],
97
- task[:params]
98
- )
94
+ if !task[:force_processing] && task[:skip_if_exists] && File.exist?(task[:output_path]) &&
95
+ !@operation_processor.needs_processing?(
96
+ task[:input_path],
97
+ task[:output_path],
98
+ task[:params]
99
+ )
99
100
  Jekyll.logger.debug "⏭️ Skipping #{task[:original_name]} - already up-to-date"
100
101
  mark_completed(task, :skipped)
101
102
  return
@@ -104,7 +105,8 @@ module JekyllImgFlow
104
105
  # Process the operation
105
106
  operation = {
106
107
  type: task[:operation_type],
107
- params: task[:params]
108
+ params: task[:params],
109
+ file_digest: task[:file_digest]
108
110
  }
109
111
 
110
112
  Jekyll.logger.debug "🔍 BatchManager: Processing task - input: #{task[:input_path]}, original: #{task[:original_name]}"
@@ -201,7 +203,7 @@ module JekyllImgFlow
201
203
  # @param config [Config] ImgFlow configuration
202
204
  # @param site [Jekyll::Site] Jekyll site object
203
205
  # @return [Array<Hash>] Array of tasks
204
- def self.build_default_tasks(original_name, input_path, config, _site)
206
+ def self.build_default_tasks(original_name, input_path, config, _site, file_digest: nil)
205
207
  tasks = []
206
208
 
207
209
  # Use FilenameGenerator for consistent naming
@@ -233,6 +235,7 @@ module JekyllImgFlow
233
235
  },
234
236
  version_type: :default,
235
237
  page_path: nil, # Default versions not tied to specific page
238
+ file_digest: file_digest,
236
239
  skip_if_exists: true
237
240
  }
238
241
  end
@@ -13,29 +13,31 @@ module JekyllImgFlow
13
13
 
14
14
  # Initialize operation processor with current provider
15
15
  provider = @registry.current_provider
16
- @operation_processor = OperationProcessor.new(provider, @path_resolver, @manifest, @config)
16
+ @operation_processor = OperationProcessor.new(provider, @path_resolver, @manifest, @config) if provider
17
17
 
18
18
  # Initialize batch manager
19
19
  @batch_manager = BatchManager.new(@operation_processor)
20
20
 
21
21
  # Store all components in site for sharing with ImgFlow tag
22
- return if @site.imgflow_components
23
-
24
- filename_generator = JekyllImgFlow::FilenameGenerator.new
22
+ @filename_generator = JekyllImgFlow::FilenameGenerator.new
25
23
 
26
24
  @site.imgflow_components = {
27
25
  config: @config,
28
26
  manifest: @manifest,
29
27
  path_resolver: @path_resolver,
30
- filename_generator: filename_generator,
28
+ filename_generator: @filename_generator,
31
29
  registry: @registry,
32
30
  provider: provider,
33
- operation_processor: @operation_processor
31
+ operation_processor: @operation_processor,
32
+ stats: @operation_processor&.stats,
33
+ preset_manager: JekyllImgFlow::PresetManager.new(@site, @config)
34
34
  }
35
35
  end
36
36
 
37
37
  # Process all changed images in originals folder
38
38
  def process_changed_images
39
+ return { completed: 0, failed: 0 } unless @operation_processor
40
+
39
41
  Jekyll.logger.info "🔄 BuildTimeProcessor: Processing changed images"
40
42
 
41
43
  # Get all original images
@@ -44,27 +46,37 @@ module JekyllImgFlow
44
46
 
45
47
  originals_dir = File.join(@site.source, @config.originals)
46
48
 
49
+ # Clean up manifest entries for deleted originals
50
+ current_originals = original_paths.map { |path| path.sub("#{originals_dir}/", "") }
51
+ @manifest.cleanup_deleted_originals(current_originals)
52
+ @manifest.cleanup_obsolete_defaults(expected_default_operations)
53
+ @manifest.reset_page_usage unless incremental_build?
54
+
47
55
  # Build tasks for each original image
48
- original_paths.each do |path|
56
+ original_paths.reject { |path| AnimatedGifDetector.animated?(path) }.each do |path|
49
57
  original_name = path.sub("#{originals_dir}/", "")
58
+ file_digest = @filename_generator.file_digest(path)
50
59
 
51
60
  # Check if needs processing (file changed or provider changed)
52
- if needs_processing?(original_name, path)
61
+ if needs_processing?(original_name, path, file_digest)
53
62
  Jekyll.logger.info "🔧 Queuing default versions for: #{original_name}"
54
63
 
55
64
  # Build default tasks
56
- tasks = BatchManager.build_default_tasks(original_name, path, @config, @site)
65
+ tasks = BatchManager.build_default_tasks(
66
+ original_name, path, @config, @site, file_digest: file_digest
67
+ )
68
+ force_processing = source_digest_changed?(original_name, file_digest)
69
+ tasks.each { |task| task[:force_processing] = force_processing }
57
70
  @batch_manager.add_tasks(tasks)
58
71
  else
72
+ expected_default_operations.length.times { @operation_processor.stats.record_cache_hit }
59
73
  Jekyll.logger.debug "⏭️ Skipping #{original_name} - already up-to-date"
60
74
  end
61
75
  end
62
76
 
63
77
  # Process all queued tasks
64
78
  results = @batch_manager.process_all
65
-
66
- # Register completed tasks in manifest
67
- register_completed_tasks(@batch_manager.completed)
79
+ register_skipped_tasks(@batch_manager.completed)
68
80
 
69
81
  # Save manifest so it's available when ImgflowTag runs
70
82
  @manifest.save
@@ -76,34 +88,19 @@ module JekyllImgFlow
76
88
 
77
89
  private
78
90
 
79
- # Register completed tasks in manifest
80
- # @param completed_tasks [Array<Hash>] Array of completed tasks from BatchManager
81
- def register_completed_tasks(completed_tasks)
82
- completed_tasks.each do |task|
83
- next unless task[:task] && task[:result]
84
-
85
- # Get provider name
86
- provider_name = @registry.current_provider.class.name.split("::").last.downcase
91
+ def register_skipped_tasks(completed_tasks)
92
+ provider_name = @registry.current_provider&.class&.provider_name || "unknown"
93
+ completed_tasks.each do |completed|
94
+ next unless completed[:status] == :skipped
87
95
 
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.
91
- absolute_path = task[:result]
92
- relative_path = absolute_path.sub(@site.source, "")
93
-
94
- # Register the version in manifest
96
+ task = completed[:task]
97
+ @operation_processor.stats.record_cache_hit
98
+ relative_path = task[:output_path].sub(@site.source, "")
95
99
  @manifest.register_version(
96
- task[:task][:original_name],
97
- relative_path,
98
- task[:task][:params],
99
- :default, # BuildTimeProcessor creates default versions
100
- nil, # page_path (default versions aren't tied to specific pages)
101
- nil, # file_digest
102
- provider_name
100
+ task[:original_name], relative_path, task[:params], :default, nil,
101
+ task[:file_digest], provider_name
103
102
  )
104
103
  end
105
-
106
- Jekyll.logger.debug "📝 Registered #{completed_tasks.length} completed tasks in manifest"
107
104
  end
108
105
 
109
106
  # Find all original images in originals folder
@@ -113,48 +110,60 @@ module JekyllImgFlow
113
110
  input_formats = @config.input_formats
114
111
 
115
112
  Dir.glob(File.join(originals_dir, "**", "*.{#{input_formats.join(',')}}"))
116
- .reject { |path| AnimatedGifDetector.animated?(path) }
117
113
  end
118
114
 
119
115
  # Check if image needs processing
120
116
  # @param original_name [String] Path to original image relative to originals directory
121
117
  # @param path [String, nil] Full path to original image (defaults to original_name)
122
118
  # @return [Boolean] True if processing needed
123
- def needs_processing?(original_name, path = nil)
119
+ def needs_processing?(original_name, path = nil, file_digest = nil)
124
120
  path ||= original_name
125
- # For single-argument calls, derive a flat original_name from the full path
126
121
  original_name = File.basename(original_name) if original_name == path
122
+ return true unless File.file?(path)
127
123
 
128
- # If no versions exist, needs processing
129
- return true unless @manifest.versions?(original_name)
124
+ file_digest ||= @filename_generator.file_digest(path)
125
+ default_versions = @manifest.get_versions(original_name)["default"] || []
126
+ current_provider = @registry.current_provider&.class&.provider_name || "unknown"
130
127
 
131
- # Check if original file was modified
132
- versions = @manifest.get_versions(original_name)
133
- default_versions = versions["default"] || []
134
-
135
- return true if default_versions.empty?
128
+ expected_default_operations.any? do |operations|
129
+ version = default_versions.find do |candidate|
130
+ @manifest.same_operations?(candidate["operations"], operations)
131
+ end
132
+ !current_default_version?(version, current_provider, file_digest)
133
+ end
134
+ end
136
135
 
137
- # Get the most recent version creation time
138
- latest_version = default_versions.max_by { |v| v["created_at"] || 0 }
139
- version_time = latest_version["created_at"]
136
+ def expected_default_operations
137
+ @config.sizes.each_value.flat_map do |width|
138
+ @config.formats.map do |format|
139
+ { width: width, format: format, quality: @config.quality }
140
+ end
141
+ end
142
+ end
140
143
 
141
- # If original is newer than versions, needs processing
142
- return true unless version_time
144
+ def source_digest_changed?(original_name, file_digest)
145
+ digests = (@manifest.get_versions(original_name)["default"] || []).filter_map do |version|
146
+ version["file_digest"]
147
+ end
148
+ digests.any? && digests.any? { |digest| digest != file_digest }
149
+ end
143
150
 
144
- original_mtime = File.mtime(path).to_i
145
- return true if original_mtime > version_time
151
+ def incremental_build?
152
+ @site.is_a?(Jekyll::Site) && @site.incremental?
153
+ end
146
154
 
147
- # No timestamp means we need to process
155
+ def current_default_version?(version, current_provider, file_digest)
156
+ return false unless version && version["provider"] == current_provider
157
+ return false unless version["file_digest"] == file_digest
148
158
 
149
- # Check if provider changed
150
- current_provider = @registry.current_provider.class.provider_name
151
- version_providers = default_versions.map { |v| v["provider"] }
152
- version_providers.uniq!
159
+ output_path = version["output"]
160
+ return false unless output_path
153
161
 
154
- return true unless version_providers.include?(current_provider)
162
+ absolute_path = File.expand_path(output_path.delete_prefix("/"), @site.source)
163
+ output_root = File.expand_path(@config.output, @site.source)
164
+ return false unless absolute_path.start_with?("#{output_root}#{File::SEPARATOR}")
155
165
 
156
- # Up-to-date
157
- false
166
+ File.file?(absolute_path)
158
167
  end
159
168
  end
160
169
  end
@@ -20,11 +20,12 @@ module JekyllImgFlow
20
20
  # Format used for the <img> fallback in <picture> elements.
21
21
  # All other formats in `formats` get <source> tags for browsers that support them.
22
22
  DEFAULT_FALLBACK_FORMAT = "jpg"
23
+ DEFAULT_IMAGE_MODAL = true
23
24
 
24
25
  attr_reader :site, :originals, :output, :input_formats, :sizes, :formats,
25
26
  :quality, :backend_priority, :imgproxy_url,
26
- :image_compressor_url, :weserv_url, :flyimg_url,
27
- :optimize_qualities, :fallback_format
27
+ :weserv_url, :flyimg_url,
28
+ :optimize_qualities, :fallback_format, :image_modal
28
29
 
29
30
  def initialize(site)
30
31
  shared = site.config["shared_images_configs"] || {}
@@ -40,9 +41,9 @@ module JekyllImgFlow
40
41
  @quality = cfg["quality"] || DEFAULT_QUALITY
41
42
  @backend_priority = cfg["backend_priority"] || DEFAULT_BACKEND_PRIORITY
42
43
  @fallback_format = cfg["fallback_format"] || DEFAULT_FALLBACK_FORMAT
44
+ @image_modal = cfg.fetch("image_modal", DEFAULT_IMAGE_MODAL)
43
45
 
44
46
  @imgproxy_url = cfg["imgproxy_url"]
45
- @image_compressor_url = cfg["image_compressor_url"]
46
47
  @weserv_url = cfg["weserv_url"]
47
48
  @flyimg_url = cfg["flyimg_url"]
48
49
  @optimize_qualities = cfg["optimize_qualities"] || {
@@ -64,12 +65,10 @@ module JekyllImgFlow
64
65
  {
65
66
  enabled: merged_config.key?("imgproxy_url") ||
66
67
  merged_config.key?("weserv_url") ||
67
- merged_config.key?("flyimg_url") ||
68
- merged_config.key?("image_compressor_url"),
68
+ merged_config.key?("flyimg_url"),
69
69
  imgproxy_url: @imgproxy_url,
70
70
  weserv_url: @weserv_url,
71
- flyimg_url: @flyimg_url,
72
- image_compressor_url: @image_compressor_url
71
+ flyimg_url: @flyimg_url
73
72
  }
74
73
  end
75
74
 
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "digest"
4
- require "json"
5
4
 
6
5
  module JekyllImgFlow
7
6
  # Generates unique filenames for processed images
@@ -11,10 +10,18 @@ module JekyllImgFlow
11
10
  # @param original_name [String] Original image filename
12
11
  # @param operations [Hash] Operations applied to image
13
12
  # @return [String] Generated filename (without path)
13
+ # Formats that are valid for output (raster only).
14
+ # SVG and other vector formats are input-only — image providers
15
+ # always produce raster output (webp, avif, jpg, png).
16
+ VECTOR_FORMATS = %w[svg].freeze
17
+
14
18
  def generate_filename(original_name, operations)
15
19
  base_name = File.basename(original_name, ".*")
16
20
  width = operations[:width]
17
21
  format = operations[:format] || File.extname(original_name).delete(".")
22
+ # Vector formats (e.g. SVG) are input-only — never use them as
23
+ # output format. Fall back to jpg (universal <img> fallback).
24
+ format = "jpg" if VECTOR_FORMATS.include?(format.to_s.downcase)
18
25
 
19
26
  # Generate hash using Jekyll Picture Tag approach
20
27
  hash = generate_jpt_hash(original_name, operations)
@@ -22,15 +29,6 @@ module JekyllImgFlow
22
29
  "#{base_name}-#{width}-#{hash}.#{format}"
23
30
  end
24
31
 
25
- # Generate cache key for operations (SHA256 for manifest tracking)
26
- # @param operations [Hash] Operations hash
27
- # @return [String] Cache key
28
- def generate_cache_key(operations)
29
- # Sort keys to ensure consistent hashing
30
- sorted_operations = operations.sort.to_h
31
- Digest::SHA256.hexdigest(sorted_operations.to_json)
32
- end
33
-
34
32
  # Get file digest for manifest tracking
35
33
  # @param original_path [String] Path to original image
36
34
  # @return [String] SHA256 digest of file
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module JekyllImgFlow
6
+ class GeneratedFileCleaner
7
+ def initialize(site, config)
8
+ @site = site
9
+ @config = config
10
+ end
11
+
12
+ def delete(output_path)
13
+ return false unless output_path
14
+
15
+ unregister_static_file(output_path)
16
+ deleted = false
17
+ [@site.source, @site.dest].each do |base_path|
18
+ path = safe_generated_path(base_path, output_path)
19
+ next unless path && File.file?(path)
20
+
21
+ File.delete(path)
22
+ remove_empty_output_dirs(File.dirname(path), output_root(base_path))
23
+ deleted = true
24
+ end
25
+ deleted
26
+ end
27
+
28
+ def clear
29
+ roots = [@site.source, @site.dest].map { |base_path| output_root(base_path) }
30
+ valid = roots.zip([@site.source, @site.dest]).all? do |directory, base_path|
31
+ base = File.expand_path(base_path)
32
+ directory.start_with?("#{base}#{File::SEPARATOR}")
33
+ end
34
+ raise "Refusing to delete optimized directory outside configured roots" unless valid
35
+
36
+ unregister_output_tree(roots.first)
37
+ files = roots.flat_map do |directory|
38
+ Dir.glob(File.join(directory, "**", "*")).select { |path| File.file?(path) }
39
+ end
40
+ files = files.uniq
41
+ files.each { |path| File.delete(path) }
42
+ files.length
43
+ end
44
+
45
+ private
46
+
47
+ def unregister_static_file(output_path)
48
+ return unless @site.is_a?(Jekyll::Site)
49
+
50
+ source_path = safe_generated_path(@site.source, output_path)
51
+ @site.static_files.reject! do |static_file|
52
+ source_path && File.expand_path(static_file.path) == source_path
53
+ end
54
+ end
55
+
56
+ def unregister_output_tree(root)
57
+ return unless @site.is_a?(Jekyll::Site)
58
+
59
+ @site.static_files.reject! do |static_file|
60
+ File.expand_path(static_file.path).start_with?("#{root}#{File::SEPARATOR}")
61
+ end
62
+ end
63
+
64
+ def safe_generated_path(base_path, output_path)
65
+ root = output_root(base_path)
66
+ candidate = File.expand_path(output_path.delete_prefix("/"), base_path)
67
+ candidate if candidate.start_with?("#{root}#{File::SEPARATOR}")
68
+ end
69
+
70
+ def output_root(base_path)
71
+ File.expand_path(@config.output, base_path)
72
+ end
73
+
74
+ def remove_empty_output_dirs(directory, root)
75
+ while directory.start_with?("#{root}#{File::SEPARATOR}") && Dir.empty?(directory)
76
+ Dir.rmdir(directory)
77
+ directory = File.dirname(directory)
78
+ end
79
+ end
80
+ end
81
+ end
@@ -11,6 +11,12 @@ module JekyllImgFlow
11
11
  site.define_singleton_method(:imgflow_components=) { |value| @imgflow_components = value }
12
12
  end
13
13
 
14
+ if site.respond_to?(:config)
15
+ cache_dir = Config.new(site).cache_dir
16
+ site.config["exclude"] = Array(site.config["exclude"])
17
+ site.config["exclude"] << cache_dir unless site.config["exclude"].include?(cache_dir)
18
+ end
19
+
14
20
  # Components will be initialized on first use (lazy loading)
15
21
  Jekyll.logger.info "✅ ImgFlow: Components ready for initialization"
16
22
  end
@@ -31,22 +37,30 @@ module JekyllImgFlow
31
37
  Jekyll.logger.info "🔌 ImgFlow post_write hook - saving final manifest with page usage"
32
38
 
33
39
  if site.imgflow_components && site.imgflow_components[:manifest]
34
- site.imgflow_components[:manifest].save
40
+ manifest = site.imgflow_components[:manifest]
41
+ cleanup_orphaned_images(site, manifest) if Jekyll.env != "development" &&
42
+ !(site.is_a?(Jekyll::Site) && site.incremental?)
43
+ manifest.save
35
44
  Jekyll.logger.info "✅ ImgFlow: Final manifest saved with all page usage data"
36
45
  else
37
46
  Jekyll.logger.warn "⚠️ ImgFlow: No manifest found to save"
38
47
  end
48
+ end
39
49
 
40
- # Clean up orphaned specialized images in production
41
- cleanup_orphaned_images(site) if Jekyll.env != "development"
50
+ # Inject modal CSS/JS into pages that contain data-imgflow-modal triggers
51
+ %i[pages documents].each do |type|
52
+ Jekyll::Hooks.register type, :post_render do |doc|
53
+ doc.output = ModalAssets.inject(doc.output) if doc.output
54
+ end
42
55
  end
43
56
 
44
- def self.cleanup_orphaned_images(site)
57
+ def self.cleanup_orphaned_images(site, manifest = nil)
45
58
  Jekyll.logger.info "🧹 ImgFlow: Checking for orphaned specialized images..."
46
59
 
47
60
  # Use ManifestManager to find and cleanup orphans
48
- manifest = ManifestManager.new(site)
61
+ manifest ||= ManifestManager.new(site)
49
62
  cleaned = manifest.cleanup_orphans
63
+ manifest.save
50
64
 
51
65
  if cleaned.any?
52
66
  Jekyll.logger.info "✅ ImgFlow: Removed #{cleaned.length} orphaned specialized images"
@@ -4,8 +4,7 @@ module JekyllImgFlow
4
4
  # Unified HTML Generator for all markup formats
5
5
  # Supports Picture Tag compatibility with multiple markup formats and attributes
6
6
  class HtmlGenerator
7
- MARKUP_FORMATS = %w[auto picture img data_auto data_picture data_img direct_url
8
- naked_srcset].freeze
7
+ include ModalWrapper
9
8
 
10
9
  # Generate HTML based on markup format and attributes
11
10
  # @param results [Array<String>] Processed image paths
@@ -50,6 +49,9 @@ module JekyllImgFlow
50
49
  # Wrap in link if specified
51
50
  html = wrap_with_link(html) if @attributes[:link]
52
51
 
52
+ # Wrap in modal trigger if enabled (only for HTML elements, not raw URLs)
53
+ html = wrap_with_modal(html) if modal_enabled? && html_element?
54
+
53
55
  # Wrap in parent container if specified
54
56
  html = wrap_with_parent(html) if @attributes[:parent]&.any?
55
57
 
@@ -80,7 +82,8 @@ module JekyllImgFlow
80
82
  a: attributes[:a] || {},
81
83
  parent: attributes[:parent] || {},
82
84
  alt: attributes[:alt],
83
- link: attributes[:link]
85
+ link: attributes[:link],
86
+ modal: attributes[:modal]
84
87
  }
85
88
  end
86
89
 
@@ -92,7 +95,8 @@ module JekyllImgFlow
92
95
  a: {},
93
96
  parent: {},
94
97
  alt: nil,
95
- link: nil
98
+ link: nil,
99
+ modal: nil
96
100
  }
97
101
  end
98
102
 
@@ -332,11 +336,20 @@ module JekyllImgFlow
332
336
  .gsub(">", "&gt;")
333
337
  end
334
338
 
335
- # Get correct path for HTML generation (relative paths for Jekyll sites)
339
+ # Get correct path for HTML generation.
336
340
  def html_path(relative_path)
337
- # For HTML generation, we want relative paths within the site
338
- # Remove leading slash to make it relative to site root
339
- relative_path.start_with?("/") ? relative_path[1..] : relative_path
341
+ path = "/#{relative_path.delete_prefix('/')}"
342
+ baseurl = html_baseurl
343
+ return path if baseurl.empty? || path == baseurl || path.start_with?("#{baseurl}/")
344
+
345
+ "#{baseurl}#{path}"
346
+ end
347
+
348
+ def html_baseurl
349
+ site = @context&.registers&.dig(:site) || @config&.site
350
+ baseurl = site&.config&.fetch("baseurl", "").to_s
351
+ normalized = baseurl.gsub(%r{\A/+|/+$}, "")
352
+ normalized.empty? ? "" : "/#{normalized}"
340
353
  end
341
354
 
342
355
  # Get absolute URL when needed (for direct_url format)
@@ -345,18 +358,7 @@ module JekyllImgFlow
345
358
 
346
359
  site = @context.registers[:site]
347
360
  base_url = site.config["url"] || "http://localhost:4000"
348
- baseurl = site.config["baseurl"] || ""
349
-
350
- # Ensure base_url ends without trailing slash and baseurl starts without slash
351
- base_url = base_url.chomp("/")
352
- baseurl = baseurl[1..] if baseurl.start_with?("/")
353
-
354
- # Build URL parts carefully to avoid double slashes
355
- url_parts = [base_url]
356
- url_parts << baseurl unless baseurl.empty?
357
- url_parts << html_path(relative_path)
358
-
359
- url_parts.join("/")
361
+ "#{base_url.chomp('/')}#{html_path(relative_path)}"
360
362
  end
361
363
  end
362
364
  end