middleman-s3_sync 4.6.5 → 4.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae6310b6bdcb35bb9bdeb6b35b5a5471321cd2aa31e2301cb2b85bb40f06c6d8
4
- data.tar.gz: d0a22baf1a2f20e5e2ea4ffa0a727042ad42b8d0234ee058925a83c85557705a
3
+ metadata.gz: 1ca1bccd213b5e15c13b3c24763c20164493043d5ed3772b90ac38dc8391293d
4
+ data.tar.gz: 5f2b316721b80826dfe8b33f96ba3b5389b455260a256ff8cf7562cd563a1d28
5
5
  SHA512:
6
- metadata.gz: 133e5c9a90cd90800cb4c63e443a529b5ea7d0c3d2f803972b58b0ccc01a0af4c0188932f7d0bb3030e42d157c7722f3e8e2cc8c0757c9d5207075fe016653ad
7
- data.tar.gz: 0d1438c52b583df33a8bce0ac0faab99d13675470092c70facd356508e096c55fdfc4d64e4bb2a58bdc03ba3cda88afcc7b378f3f5f58374939bd678b46f1de2
6
+ metadata.gz: 3ce61465bdc1d5986030e772d632fc425bc7d97b3d2d948ae5afee9bda9f5b92edb44cfd7bef8ad9d3f435a5c111945091f1e62428d3fd20d803a06a5e4f8e7d
7
+ data.tar.gz: a97276e8b0246ca38c8e5f95a0628b18d980185b366b558979dddeeb414ef2538201f3ce8697d9294390154d096676a71d2545b3fb1f84836d86d72feb53483f
@@ -0,0 +1,29 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+ branches: [master]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ ruby-version: ['3.1', '3.2', '3.3', '3.4']
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - name: Set up Ruby ${{ matrix.ruby-version }}
20
+ uses: ruby/setup-ruby@v1
21
+ with:
22
+ ruby-version: ${{ matrix.ruby-version }}
23
+ bundler-cache: true
24
+
25
+ - name: Run tests
26
+ run: bundle exec rspec
27
+
28
+ - name: Build gem
29
+ run: gem build middleman-s3_sync.gemspec
@@ -0,0 +1,53 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ jobs:
9
+ release:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: write # For creating GitHub releases
13
+
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - name: Set up Ruby
18
+ uses: ruby/setup-ruby@v1
19
+ with:
20
+ ruby-version: '3.4'
21
+ bundler-cache: true
22
+
23
+ - name: Run tests
24
+ run: bundle exec rspec
25
+
26
+ - name: Build gem
27
+ run: gem build middleman-s3_sync.gemspec
28
+
29
+ - name: Get version
30
+ id: version
31
+ run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
32
+
33
+ - name: Generate checksums
34
+ run: |
35
+ sha256sum middleman-s3_sync-*.gem > checksums.txt
36
+ cat checksums.txt
37
+
38
+ - name: Publish to RubyGems
39
+ run: |
40
+ mkdir -p ~/.gem
41
+ echo -e "---\n:rubygems_api_key: ${RUBYGEMS_API_KEY}" > ~/.gem/credentials
42
+ chmod 0600 ~/.gem/credentials
43
+ gem push middleman-s3_sync-*.gem
44
+ env:
45
+ RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
46
+
47
+ - name: Create GitHub Release
48
+ uses: softprops/action-gh-release@v2
49
+ with:
50
+ files: |
51
+ middleman-s3_sync-*.gem
52
+ checksums.txt
53
+ generate_release_notes: true
data/Changelog.md CHANGED
@@ -2,6 +2,60 @@
2
2
 
3
3
  The gem that tries really hard not to push files to S3.
4
4
 
5
+ ## v4.8.0
6
+ - Add `immutable` directive to caching policies. Pair with a long `max_age` on
7
+ fingerprinted assets (e.g. those produced by Middleman's `asset_hash`
8
+ extension) to tell browsers they never need to revalidate:
9
+ `caching_policy 'text/css', max_age: 1.year, public: true, immutable: true`.
10
+ - Prefer `Cache-Control: max-age` over the `Expires` header. When a policy sets
11
+ `max_age:`, the `Expires` header is now suppressed even if `expires:` is also
12
+ set. Per RFC 7234 §5.3, `max-age` overrides `Expires` for HTTP/1.1 caches, so
13
+ emitting both adds no information and forces a metadata update on every build
14
+ as the `expires:` timestamp drifts forward. Existing configs that rely solely
15
+ on `expires:` (without `max_age:`) continue to work unchanged.
16
+ - Resource uploads no longer include empty `cache_control` or `expires` keys
17
+ when the caching policy yields `nil` for them.
18
+ - Drop `timerizer` development dependency. Its monkey-patch of `Time.new` was
19
+ incompatible with Ruby 3.1.7's keyword-argument changes and crashed RSpec at
20
+ startup on the 3.1 CI matrix. The three test usages were replaced with plain
21
+ `Time` literals.
22
+
23
+ ## v4.7.0
24
+ - Add `after_s3_sync` callback hook that runs after sync completes (#138).
25
+ Accepts a lambda/proc — optionally receiving a results hash with `created`,
26
+ `updated`, `deleted` counts and invalidation paths — or a symbol referencing
27
+ a method on the Middleman app. Zero-arg callbacks work without modification
28
+ via arity detection. Callback failures are logged but do not abort the sync.
29
+ - Add `scan_build_dir` option (default: `false`) to sync files in the build
30
+ directory that aren't in the Middleman sitemap (#108, #137). Useful for
31
+ output from `after_build` callbacks, image optimizers, or anything placed
32
+ in `build/` outside the sitemap.
33
+ - Add `routing_rules` option to configure S3 website routing rules at sync
34
+ time, so deployments don't overwrite manually-configured rules (#142).
35
+ Supports `condition.key_prefix_equals`,
36
+ `condition.http_error_code_returned_equals`, and the
37
+ `redirect.{host_name, http_redirect_code, protocol, replace_key_prefix_with,
38
+ replace_key_with}` keys. Requires `index_document` to also be set.
39
+ - Improve content type detection with a `mime-types` fallback for files
40
+ Middleman doesn't classify (e.g. orphan files, WebP, woff2). The
41
+ `content_types` option is now checked first, and unknown extensions default
42
+ to `application/octet-stream`. Bumped `mime-types` constraint to `~> 3.4`.
43
+ (#161)
44
+ - Fix sitemap population for build-mode extensions (#116, #128). The sync
45
+ now calls `ensure_resource_list_updated!` so extensions like blog and
46
+ asset_hash populate the sitemap, and always runs in `:build` mode so
47
+ `configure :build` blocks are active. Files emitted by `after_build`
48
+ callbacks still aren't visible to the sitemap — use `scan_build_dir` for
49
+ those.
50
+ - Fix `Resource#redirect?` to return `true`/`false` instead of a truthy URL
51
+ string (#143). The status logic was already correct; this just normalizes
52
+ the boolean contract.
53
+ - Tighten gemspec dependency bounds and require Ruby `>= 3.0` (#167).
54
+ Pessimistic constraints on all runtime and development deps to resolve the
55
+ open-ended-dependency warnings on `gem build`.
56
+ - Add GitHub Actions CI matrix (Ruby 3.1, 3.2, 3.3, 3.4) and an automated
57
+ RubyGems release workflow that publishes on `v*` tags (#169).
58
+
5
59
  ## v4.6.5
6
60
  - Performance and stability improvements
7
61
  - Thread-safe invalidation path tracking (use Set + mutex) when running in parallel
data/README.md CHANGED
@@ -19,6 +19,33 @@ that are no longer needed.
19
19
  * Use middleman-s3_sync version 4.x for Middleman 4.x
20
20
  * Use middleman-s3_sync version 3.x for Middleman 3.x
21
21
 
22
+ ## What's New in 4.7.0
23
+
24
+ **New Features**
25
+ - `after_s3_sync` callback for post-sync hooks (notifications, custom actions)
26
+ - `scan_build_dir` option to sync files outside the Middleman sitemap
27
+ - `routing_rules` option for S3 website redirect configuration
28
+ - Improved content type detection with mime-types gem fallback
29
+
30
+ **Performance & Efficiency**
31
+ - Batch deletes using S3 `delete_objects` (up to 1,000 keys per request)
32
+ - Streaming uploads to reduce memory usage on large files
33
+ - Single-pass MD5 computation avoids redundant file reads
34
+ - Single-pass resource categorization (create/update/delete)
35
+ - Faster redundant-path pruning for CloudFront invalidations
36
+
37
+ **Reliability**
38
+ - Thread-safe CloudFront invalidation path tracking (mutex-protected Set)
39
+ - Cached CloudFront client to reduce re-instantiation overhead
40
+ - Proper sitemap population before sync (`ensure_resource_list_updated!`)
41
+ - Fixed redirect detection to return boolean values
42
+
43
+ **Developer Experience**
44
+ - Extension now properly delegates option writers (`verbose=`, `dry_run=`, etc.)
45
+ - GitHub Actions CI and release workflows
46
+ - Tightened gemspec with bounded dependency versions
47
+ - Ruby >= 3.0 requirement
48
+
22
49
  ## Installation
23
50
 
24
51
  Add this line to your application's Gemfile:
@@ -258,6 +285,57 @@ Your AWS credentials need CloudFront permissions in addition to S3:
258
285
  - Use `cloudfront_invalidate_all: true` for major updates to minimize costs (counts as 1 path)
259
286
  - Consider the trade-off between immediate cache invalidation and cost
260
287
 
288
+ ## Callbacks
289
+
290
+ ### after_s3_sync
291
+
292
+ You can configure a callback that runs after the sync completes. This is useful for triggering notifications, updating external services, or running post-deployment tasks.
293
+
294
+ ```ruby
295
+ activate :s3_sync do |s3_sync|
296
+ # ... other configuration ...
297
+
298
+ # Using a lambda/proc
299
+ s3_sync.after_s3_sync = ->(results) {
300
+ puts "Created: #{results[:created]} files"
301
+ puts "Updated: #{results[:updated]} files"
302
+ puts "Deleted: #{results[:deleted]} files"
303
+ puts "Invalidation paths: #{results[:invalidation_paths].join(', ')}"
304
+ }
305
+ end
306
+ ```
307
+
308
+ The callback receives a hash with sync results:
309
+
310
+ | Key | Type | Description |
311
+ | --------------------- | ------- | ----------- |
312
+ | `:created` | Integer | Number of files created |
313
+ | `:updated` | Integer | Number of files updated |
314
+ | `:deleted` | Integer | Number of files deleted |
315
+ | `:invalidation_paths` | Array | CloudFront paths that were invalidated |
316
+
317
+ You can also use a symbol to call a method on the Middleman app:
318
+
319
+ ```ruby
320
+ # In config.rb
321
+ def notify_slack(results)
322
+ # Send deployment notification to Slack
323
+ end
324
+
325
+ activate :s3_sync do |s3_sync|
326
+ # ... other configuration ...
327
+ s3_sync.after_s3_sync = :notify_slack
328
+ end
329
+ ```
330
+
331
+ Callbacks that take no arguments are also supported:
332
+
333
+ ```ruby
334
+ activate :s3_sync do |s3_sync|
335
+ s3_sync.after_s3_sync = -> { puts "Sync complete!" }
336
+ end
337
+ ```
338
+
261
339
  #### IAM Policy
262
340
 
263
341
  Here's a sample IAM policy with least-privilege permissions that will allow syncing to a bucket named "mysite.com":
@@ -468,6 +546,21 @@ The following keys can be set:
468
546
  | `no_store` | boolean | `no-store` | Instructs caches not to keep a copy of the representation under any conditions. |
469
547
  | `must_revalidate` | boolean | `must-revalidate` | Tells the caches that they must obey any freshness information you give them about a representation. |
470
548
  | `proxy_revalidate` | boolean | `proxy-revalidate` | Similar as `must-revalidate`, but only for proxies. |
549
+ | `immutable` | boolean | `immutable` | Tells browsers the response body will never change for this URL, so they should not revalidate even on a user-initiated reload. Pair with a long `max_age` for content-addressed (fingerprinted) assets. |
550
+
551
+ #### Hashed Assets
552
+
553
+ If you use Middleman's `asset_hash` extension, fingerprinted asset URLs
554
+ are content-addressed and never need to be revalidated. Combining a
555
+ long `max_age` with `immutable` is the strongest browser cache hint
556
+ you can give:
557
+
558
+ ```ruby
559
+ caching_policy 'text/css', max_age: 1.year, public: true, immutable: true
560
+ caching_policy 'application/javascript', max_age: 1.year, public: true, immutable: true
561
+ caching_policy 'image/png', max_age: 1.year, public: true, immutable: true
562
+ caching_policy 'image/jpeg', max_age: 1.year, public: true, immutable: true
563
+ ```
471
564
 
472
565
  #### Setting `Expires` Header
473
566
 
@@ -476,8 +569,11 @@ You can pass the `expires` key to the `caching_policy` and
476
569
  header on a results. You will need to pass it a Time object indicating
477
570
  when the resource is set to expire.
478
571
 
479
- > Note that the `Cache-Control` header will take precedence over the
480
- > `Expires` header if both are present.
572
+ > Note that the `Cache-Control` header takes precedence over `Expires`
573
+ > for HTTP/1.1 caches (RFC 7234 §5.3). For that reason, when a policy
574
+ > sets `max_age:`, the `Expires` header is suppressed entirely — even
575
+ > if `expires:` is also set. This avoids redundant metadata churn from
576
+ > the `expires:` timestamp drifting forward on every build.
481
577
 
482
578
  #### A Note About Browser Caching
483
579
 
@@ -514,6 +610,16 @@ The full values and their semantics are [documented on AWS's
514
610
  documentation
515
611
  site](http://docs.aws.amazon.com/AmazonS3/latest/dev/ACLOverview.html#CannedACL).
516
612
 
613
+ ##### Buckets with ACLs Disabled
614
+
615
+ If your bucket uses "Object Ownership: Bucket owner enforced" (ACLs disabled), set:
616
+
617
+ ```ruby
618
+ s3_sync.acl = '' # or: s3_sync.acl = nil
619
+ ```
620
+
621
+ The gem will also auto-detect buckets that reject ACL headers and transparently retry uploads without the `:acl` parameter.
622
+
517
623
  #### Encryption
518
624
 
519
625
  You can ask Amazon to encrypt your files at rest by setting the
@@ -38,6 +38,7 @@ module Middleman
38
38
  policy << "no-store" if policies.fetch(:no_store, false)
39
39
  policy << "must-revalidate" if policies.fetch(:must_revalidate, false)
40
40
  policy << "proxy-revalidate" if policies.fetch(:proxy_revalidate, false)
41
+ policy << "immutable" if policies.fetch(:immutable, false)
41
42
  if policy.empty?
42
43
  nil
43
44
  else
@@ -49,7 +50,12 @@ module Middleman
49
50
  cache_control
50
51
  end
51
52
 
53
+ # Returns an RFC 1123 date for the Expires header.
54
+ # When :max_age is set we suppress Expires entirely: per RFC 7234 §5.3
55
+ # max-age takes precedence over Expires for HTTP/1.1 caches, so emitting
56
+ # both adds no information and forces a metadata update on every build.
52
57
  def expires
58
+ return nil if policies.has_key?(:max_age)
53
59
  if expiration = policies.fetch(:expires, nil)
54
60
  CGI.rfc1123_date(expiration)
55
61
  end
@@ -26,13 +26,16 @@ module Middleman
26
26
  :ignore_paths,
27
27
  :index_document,
28
28
  :error_document,
29
+ :routing_rules,
30
+ :scan_build_dir,
29
31
  :cloudfront_distribution_id,
30
32
  :cloudfront_invalidate,
31
33
  :cloudfront_invalidate_all,
32
34
  :cloudfront_invalidation_batch_size,
33
35
  :cloudfront_invalidation_max_retries,
34
36
  :cloudfront_invalidation_batch_delay,
35
- :cloudfront_wait
37
+ :cloudfront_wait,
38
+ :after_s3_sync
36
39
  ]
37
40
  attr_accessor *OPTIONS
38
41
 
@@ -113,6 +116,14 @@ module Middleman
113
116
  @version_bucket.nil? ? false : @version_bucket
114
117
  end
115
118
 
119
+ def routing_rules
120
+ @routing_rules || []
121
+ end
122
+
123
+ def scan_build_dir
124
+ @scan_build_dir.nil? ? false : @scan_build_dir
125
+ end
126
+
116
127
  end
117
128
  end
118
129
  end
@@ -6,9 +6,11 @@ module Middleman
6
6
 
7
7
  include Status
8
8
 
9
- def initialize(resource, partial_s3_resource)
9
+ def initialize(resource, partial_s3_resource, path: nil)
10
10
  @resource = resource
11
- @path = if resource
11
+ @path = if path
12
+ path.sub(/^\//, '')
13
+ elsif resource
12
14
  resource.destination_path.sub(/^\//, '')
13
15
  elsif partial_s3_resource&.key
14
16
  partial_s3_resource.key.sub(/^\//, '')
@@ -62,8 +64,8 @@ module Middleman
62
64
  attributes[:acl] = options.acl if options.acl_enabled?
63
65
 
64
66
  if caching_policy
65
- attributes[:cache_control] = caching_policy.cache_control
66
- attributes[:expires] = caching_policy.expires
67
+ attributes[:cache_control] = caching_policy.cache_control if caching_policy.cache_control
68
+ attributes[:expires] = caching_policy.expires if caching_policy.expires
67
69
  end
68
70
 
69
71
  if options.prefer_gzip && gzipped
@@ -224,7 +226,8 @@ module Middleman
224
226
  end
225
227
 
226
228
  def local?
227
- File.exist?(local_path) && resource
229
+ # For orphan files (scan_build_dir), resource is nil but file exists
230
+ File.exist?(local_path)
228
231
  end
229
232
 
230
233
  def remote?
@@ -232,8 +235,8 @@ module Middleman
232
235
  end
233
236
 
234
237
  def redirect?
235
- (resource && resource.respond_to?(:redirect?) && resource.redirect?) ||
236
- (full_s3_resource && full_s3_resource.respond_to?(:website_redirect_location) && full_s3_resource.website_redirect_location)
238
+ !!(resource && resource.respond_to?(:redirect?) && resource.redirect?) ||
239
+ !!(full_s3_resource && full_s3_resource.respond_to?(:website_redirect_location) && full_s3_resource.website_redirect_location)
237
240
  end
238
241
 
239
242
  def metadata_match?
@@ -323,8 +326,24 @@ module Middleman
323
326
  end
324
327
 
325
328
  def content_type
326
- @content_type ||= Middleman::S3Sync.content_types[local_path]
327
- @content_type ||= !resource.nil? && resource.respond_to?(:content_type) ? resource.content_type : nil
329
+ @content_type ||= begin
330
+ # Priority: content_types option > mm_resource > mime-types > default
331
+ ct = options.content_types[local_path] if options.content_types
332
+ ct ||= options.content_types[path] if options.content_types
333
+ ct ||= Middleman::S3Sync.content_types[local_path]
334
+ ct ||= Middleman::S3Sync.content_types[path]
335
+ ct ||= resource.content_type if resource&.respond_to?(:content_type)
336
+ ct ||= detect_content_type_from_extension
337
+ ct || 'application/octet-stream'
338
+ end
339
+ end
340
+
341
+ def detect_content_type_from_extension
342
+ return nil unless defined?(MIME::Types)
343
+ extension = File.extname(original_path).delete_prefix('.')
344
+ return nil if extension.empty?
345
+ types = MIME::Types.type_for(extension)
346
+ types.first&.content_type
328
347
  end
329
348
 
330
349
  def caching_policy
@@ -363,8 +382,8 @@ module Middleman
363
382
 
364
383
  # Add cache control and expires if present
365
384
  if caching_policy
366
- upload_options[:cache_control] = caching_policy.cache_control
367
- upload_options[:expires] = caching_policy.expires
385
+ upload_options[:cache_control] = caching_policy.cache_control if caching_policy.cache_control
386
+ upload_options[:expires] = caching_policy.expires if caching_policy.expires
368
387
  end
369
388
 
370
389
  # Add storage class if needed
@@ -1,5 +1,5 @@
1
1
  module Middleman
2
2
  module S3Sync
3
- VERSION = "4.6.5"
3
+ VERSION = "4.8.0"
4
4
  end
5
5
  end
@@ -1,5 +1,6 @@
1
1
  require 'aws-sdk-s3'
2
2
  require 'digest/md5'
3
+ require 'mime/types'
3
4
  require 'middleman/s3_sync/version'
4
5
  require 'middleman/s3_sync/options'
5
6
  require 'middleman/s3_sync/caching_policy'
@@ -37,6 +38,12 @@ module Middleman
37
38
  @app ||= ::Middleman::Application.new
38
39
  @invalidation_paths = Set.new
39
40
 
41
+ # Ensure sitemap is fully populated before syncing
42
+ # This catches resources from extensions activated in :build mode
43
+ if @app.respond_to?(:sitemap) && @app.sitemap.respond_to?(:ensure_resource_list_updated!)
44
+ @app.sitemap.ensure_resource_list_updated!
45
+ end
46
+
40
47
  say_status "Let's see if there's work to be done..."
41
48
  unless work_to_be_done?
42
49
  say_status "All S3 files are up to date."
@@ -63,6 +70,9 @@ module Middleman
63
70
  if s3_sync_options.cloudfront_invalidate
64
71
  CloudFront.invalidate(@invalidation_paths.to_a, s3_sync_options)
65
72
  end
73
+
74
+ # Run after_s3_sync callback if provided
75
+ run_after_s3_sync_callback
66
76
  end
67
77
 
68
78
  def bucket
@@ -100,6 +110,44 @@ module Middleman
100
110
  @content_types || {}
101
111
  end
102
112
 
113
+ # Run the after_s3_sync callback if configured
114
+ def run_after_s3_sync_callback
115
+ callback = s3_sync_options.after_s3_sync
116
+ return unless callback
117
+
118
+ say_status 'callback', 'Running after_s3_sync callback...'
119
+
120
+ # Build sync results hash
121
+ results = {
122
+ created: files_to_create.size,
123
+ updated: files_to_update.size,
124
+ deleted: files_to_delete.size,
125
+ invalidation_paths: @invalidation_paths.to_a
126
+ }
127
+
128
+ begin
129
+ if callback.respond_to?(:call)
130
+ # Lambda/Proc callback - check arity to decide whether to pass results
131
+ if callback.arity == 0 || callback.arity == -1 && callback.parameters.empty?
132
+ callback.call
133
+ else
134
+ callback.call(results)
135
+ end
136
+ elsif callback.is_a?(Symbol) && @app.respond_to?(callback)
137
+ # Symbol method name - call on app
138
+ if @app.method(callback).arity == 0
139
+ @app.send(callback)
140
+ else
141
+ @app.send(callback, results)
142
+ end
143
+ end
144
+
145
+ say_status 'callback', 'after_s3_sync completed successfully'
146
+ rescue => e
147
+ say_status 'error', "after_s3_sync callback failed: #{e.message}"
148
+ end
149
+ end
150
+
103
151
  protected
104
152
  def update_bucket_versioning
105
153
  s3_client.put_bucket_versioning({
@@ -115,10 +163,41 @@ module Middleman
115
163
  opts[:index_document] = { suffix: s3_sync_options.index_document } if s3_sync_options.index_document
116
164
  opts[:error_document] = { key: s3_sync_options.error_document } if s3_sync_options.error_document
117
165
 
166
+ # Add routing rules if specified
167
+ if s3_sync_options.routing_rules && !s3_sync_options.routing_rules.empty?
168
+ opts[:routing_rules] = s3_sync_options.routing_rules.map do |rule|
169
+ routing_rule = {}
170
+
171
+ # Handle condition (optional)
172
+ if rule[:condition] || rule['condition']
173
+ condition = rule[:condition] || rule['condition']
174
+ routing_rule[:condition] = {}
175
+ routing_rule[:condition][:key_prefix_equals] = condition[:key_prefix_equals] || condition['key_prefix_equals'] if condition[:key_prefix_equals] || condition['key_prefix_equals']
176
+ routing_rule[:condition][:http_error_code_returned_equals] = condition[:http_error_code_returned_equals] || condition['http_error_code_returned_equals'] if condition[:http_error_code_returned_equals] || condition['http_error_code_returned_equals']
177
+ end
178
+
179
+ # Handle redirect (required)
180
+ redirect = rule[:redirect] || rule['redirect']
181
+ routing_rule[:redirect] = {}
182
+ routing_rule[:redirect][:host_name] = redirect[:host_name] || redirect['host_name'] if redirect[:host_name] || redirect['host_name']
183
+ routing_rule[:redirect][:http_redirect_code] = redirect[:http_redirect_code] || redirect['http_redirect_code'] if redirect[:http_redirect_code] || redirect['http_redirect_code']
184
+ routing_rule[:redirect][:protocol] = redirect[:protocol] || redirect['protocol'] if redirect[:protocol] || redirect['protocol']
185
+ routing_rule[:redirect][:replace_key_prefix_with] = redirect[:replace_key_prefix_with] || redirect['replace_key_prefix_with'] if redirect[:replace_key_prefix_with] || redirect['replace_key_prefix_with']
186
+ routing_rule[:redirect][:replace_key_with] = redirect[:replace_key_with] || redirect['replace_key_with'] if redirect[:replace_key_with] || redirect['replace_key_with']
187
+
188
+ routing_rule
189
+ end
190
+ end
191
+
118
192
  if opts[:error_document] && !opts[:index_document]
119
193
  raise 'S3 requires `index_document` if `error_document` is specified'
120
194
  end
121
195
 
196
+ # S3 requires index_document if routing_rules are specified
197
+ if opts[:routing_rules] && !opts[:index_document]
198
+ raise 'S3 requires `index_document` if `routing_rules` are specified'
199
+ end
200
+
122
201
  unless opts.empty?
123
202
  say_status "Putting bucket website: #{opts.to_json}"
124
203
  s3_client.put_bucket_website({
@@ -234,6 +313,11 @@ module Middleman
234
313
  def work_to_be_done?
235
314
  Parallel.each(mm_resources, in_threads: THREADS_COUNT, progress: "Processing sitemap") { |mm_resource| add_local_resource(mm_resource) }
236
315
 
316
+ # Scan build directory for orphan files (not in sitemap)
317
+ if s3_sync_options.scan_build_dir
318
+ discover_orphan_files
319
+ end
320
+
237
321
  Parallel.each(remote_only_paths, in_threads: THREADS_COUNT, progress: "Processing remote files") do |remote_path|
238
322
  s3_sync_resources[remote_path] ||= S3Sync::Resource.new(nil, remote_resource_for_path(remote_path)).tap(&:status)
239
323
  end
@@ -241,6 +325,38 @@ module Middleman
241
325
  !(files_to_create.empty? && files_to_update.empty? && files_to_delete.empty?)
242
326
  end
243
327
 
328
+ # Discover files in build directory that are not in the sitemap
329
+ # This handles files generated by after_build callbacks, image optimizers, etc.
330
+ def discover_orphan_files
331
+ return unless build_dir && File.directory?(build_dir)
332
+
333
+ orphan_files = []
334
+
335
+ Dir.glob(File.join(build_dir, '**', '*')).each do |file_path|
336
+ next if File.directory?(file_path)
337
+
338
+ # Get path relative to build_dir
339
+ relative_path = file_path.sub(/^#{Regexp.escape(build_dir)}\/?/, '')
340
+
341
+ # Skip if already in sitemap resources
342
+ next if s3_sync_resources.key?(relative_path)
343
+
344
+ orphan_files << relative_path
345
+ end
346
+
347
+ return if orphan_files.empty?
348
+
349
+ say_status "Found #{orphan_files.size} files outside sitemap"
350
+
351
+ Parallel.each(orphan_files, in_threads: THREADS_COUNT, progress: "Processing orphan files") do |relative_path|
352
+ # Create a Resource with nil mm_resource but explicit path
353
+ # It will use mime-types for content type detection
354
+ remote = remote_resource_for_path(relative_path)
355
+ resource = S3Sync::Resource.new(nil, remote, path: relative_path)
356
+ s3_sync_resources[relative_path] = resource.tap(&:status)
357
+ end
358
+ end
359
+
244
360
  # Single-pass categorization of resources by status
245
361
  # Avoids multiple iterations over s3_sync_resources
246
362
  def categorized_resources
@@ -102,7 +102,9 @@ module Middleman
102
102
  verbose = options[:verbose] ? 0 : 1
103
103
  instrument = options[:instrument]
104
104
 
105
- mode = options[:build] ? :build : :config
105
+ # Always use :build mode to ensure build-time extensions are active
106
+ # (e.g., asset_hash in `configure :build` blocks)
107
+ mode = :build
106
108
 
107
109
  ::Middleman::S3Sync.app = ::Middleman::Application.new do
108
110
  config[:mode] = mode
@@ -26,8 +26,10 @@ module Middleman
26
26
  option :dry_run, false, 'Whether to perform a dry-run'
27
27
  option :index_document, nil, 'S3 custom index document path'
28
28
  option :error_document, nil, 'S3 custom error document path'
29
+ option :routing_rules, [], 'S3 website routing rules (array of rule hashes)'
29
30
  option :content_types, {}, 'Custom content types'
30
31
  option :ignore_paths, [], 'Paths that should be ignored during sync, strings or regex are allowed'
32
+ option :scan_build_dir, false, 'Scan build directory for files not in sitemap (e.g., after_build files)'
31
33
  option :cloudfront_distribution_id, nil, 'CloudFront distribution ID for invalidation'
32
34
  option :cloudfront_invalidate, false, 'Whether to invalidate CloudFront cache after sync'
33
35
  option :cloudfront_invalidate_all, false, 'Whether to invalidate all paths (/*) or only changed files'
@@ -35,6 +37,7 @@ module Middleman
35
37
  option :cloudfront_invalidation_max_retries, 5, 'Maximum number of retries for rate-limited invalidation requests'
36
38
  option :cloudfront_invalidation_batch_delay, 2, 'Delay in seconds between invalidation batches'
37
39
  option :cloudfront_wait, false, 'Whether to wait for CloudFront invalidation to complete'
40
+ option :after_s3_sync, nil, 'Proc/lambda to call after sync completes (receives changed_paths array)'
38
41
 
39
42
  expose_to_config :s3_sync_options, :default_caching_policy, :caching_policy
40
43