shrine 3.1.0 → 3.2.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of shrine might be problematic. Click here for more details.

checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 30bd30989579217202b2c51b9a00abe778af7cbab75fc9df78052bc074ce9405
4
- data.tar.gz: 5c718df903c203e06ea44afa9c9017fa2cf01a8924c99e86a87be52f52ce0233
3
+ metadata.gz: 1879257b21d6aafee3739bb22a37c46abfb7703dde20120445134b4f0ca56a4d
4
+ data.tar.gz: c0351dafde054c2bb126b5e16c632cf4ee34ab61c9a301d68564fe75ab1f3d5f
5
5
  SHA512:
6
- metadata.gz: 27f896211caba27686d5988750ce214ec0b31e70e9bbcf8a7d325952a5eee2e4c3293d539bb3074d4384964723bda09edd1acfcdcd387d82a4ef7373ff5787f0
7
- data.tar.gz: 3d06d57d2eb1fee1cada71dfa4092a23c4b0bf4d5df8cf76e2fbf2eb4eaf5c9477f333ef241772d917456ebfb3067ad0ce7adc6becc49e15c9129720842cd5b3
6
+ metadata.gz: 95f3ebbf6f08f799d385486464129cfd7228f04d957709593350671d066888bde0dbdc988aca6f843e0b0eb6f13e6938f81a032a272e999903de8fee5d588ca6
7
+ data.tar.gz: 4c8f7a1f64e914db4776fab9df2e9753456a095c993a626f45a1c1e1bc189eeb77f3c04187d4bd132f31a2edd163eea2fee21b360cf35a8f6991b970671d615e
@@ -1,3 +1,21 @@
1
+ ## 3.2.0 (2019-12-17) [[release notes]](https://shrinerb.com/docs/release_notes/3.2.0)
2
+
3
+ * `validation` – Run validation on `Attacher#attach` & `Attacher#attach_cached` instead of `Attacher#change` (@janko)
4
+
5
+ * `remove_invalid` – Activate also when `Attacher#validate` is run manually (@janko)
6
+
7
+ * `remove_invalid` – Fix incompatibility with `derivatives` plugin (@janko)
8
+
9
+ * `type_predicates` – Add new plugin with convenient `UploadedFile` predicate methods based on MIME type (@janko)
10
+
11
+ * `core` – Allow assigning back current attached file data (@janko)
12
+
13
+ * `derivatives` – Fix `:derivative` value inconsistency when derivatives are being promoted (@janko)
14
+
15
+ * `add_metadata` – Add `#add_metadata` method for adding metadata to uploaded files (@janko)
16
+
17
+ * `derivatives` – Add `:io` and `:attacher` values to instrumentation event payload (@janko)
18
+
1
19
  ## 3.1.0 (2019-11-15) [[release notes]](https://shrinerb.com/docs/release_notes/3.1.0)
2
20
 
3
21
  * `default_storage` – Coerce storage key to symbol in `Attacher#cache_key` & `Attacher#store_key` (@janko)
data/README.md CHANGED
@@ -8,7 +8,7 @@ Shrine is a toolkit for handling file attachments in Ruby applications. Some hig
8
8
  * **Memory friendly** – streaming uploads and [downloads][Retrieving Uploads] make it work great with large files
9
9
  * **Cloud storage** – store files on [disk][FileSystem], [AWS S3][S3], [Google Cloud][GCS], [Cloudinary] and others
10
10
  * **Persistence integrations** – works with [Sequel], [ActiveRecord], [ROM], [Hanami] and [Mongoid] and others
11
- * **Flexible processing** – generate thumbnails [up front] or [on-the-fly] using [ImageMagick] or [libvips]
11
+ * **Flexible processing** – generate thumbnails [eagerly] or [on-the-fly] using [ImageMagick] or [libvips]
12
12
  * **Metadata validation** – [validate files][validation] based on [extracted metadata][metadata]
13
13
  * **Direct uploads** – upload asynchronously [to your app][simple upload] or [to the cloud][presigned upload] using [Uppy]
14
14
  * **Resumable uploads** – make large file uploads [resumable][resumable upload] on [S3][uppy-s3_multipart] or [tus][tus-ruby-server]
@@ -149,8 +149,8 @@ The gem is available as open source under the terms of the [MIT License].
149
149
  [ROM]: https://github.com/shrinerb/shrine-rom
150
150
  [Hanami]: https://github.com/katafrakt/hanami-shrine
151
151
  [Mongoid]: https://github.com/shrinerb/shrine-mongoid
152
- [up front]: https://shrinerb.com/docs/getting-started#processing-up-front
153
- [on-the-fly]: https://shrinerb.com/docs/getting-started#processing-on-the-fly
152
+ [eagerly]: https://shrinerb.com/docs/getting-started#eager-processing
153
+ [on-the-fly]: https://shrinerb.com/docs/getting-started#on-the-fly-processing
154
154
  [ImageMagick]: https://github.com/janko/image_processing/blob/master/doc/minimagick.md#readme
155
155
  [libvips]: https://github.com/janko/image_processing/blob/master/doc/vips.md#readme
156
156
  [validation]: https://shrinerb.com/docs/validation
@@ -157,14 +157,14 @@ end
157
157
 
158
158
  ## Processing
159
159
 
160
- Most file attachment libraries provide either processing files up front
161
- (Paperclip, CarrierWave) or on-the-fly (Dragonfly, Refile, Active Storage).
160
+ Most file attachment libraries allow you to process files either "eagerly"
161
+ (Paperclip, CarrierWave) or "on-the-fly" (Dragonfly, Refile, Active Storage).
162
162
  However, each approach is suitable for different requirements. For instance,
163
163
  while on-the-fly processing is suitable for fast processing (image thumbnails,
164
164
  document previews), longer running processing (video transcoding, raw images)
165
165
  should be moved into a background job.
166
166
 
167
- That's why Shrine supports both [up front][derivatives] and
167
+ That's why Shrine supports both [eager][derivatives] and
168
168
  [on-the-fly][derivation_endpoint] processing. For example, if you're handling
169
169
  image uploads, you can choose to either generate a set of pre-defined
170
170
  thumbnails during attachment:
@@ -1,5 +1,5 @@
1
1
  ---
2
- title: Shrine for CarrierWave Users
2
+ title: Upgrading from CarrierWave
3
3
  ---
4
4
 
5
5
  This guide is aimed at helping CarrierWave users transition to Shrine, and it
@@ -476,16 +476,25 @@ photo.image.original_filename #=> "avatar.jpg"
476
476
 
477
477
  #### `#store_dir`, `#cache_dir`
478
478
 
479
- Shrine here provides a `#generate_location` method, which is triggered for all
480
- storages:
479
+ Shrine here provides a single `#generate_location` method that's triggered for
480
+ all storages:
481
481
 
482
482
  ```rb
483
483
  class ImageUploader < Shrine
484
- def generate_location(io, record: nil, **)
485
- "#{record.class}/#{record.id}/#{io.original_filename}"
484
+ def generate_location(io, record: nil, name: nil, **)
485
+ [ storage_key,
486
+ record && record.class.name.underscore,
487
+ record && record.id,
488
+ super,
489
+ io.original_filename ].compact.join("/")
486
490
  end
487
491
  end
488
492
  ```
493
+ ```
494
+ cache/user/123/2feff8c724e7ce17/nature.jpg
495
+ store/user/456/7f99669fde1e01fc/kitten.jpg
496
+ ...
497
+ ```
489
498
 
490
499
  You might also want to use the `pretty_location` plugin for automatically
491
500
  generating an organized folder structure.
@@ -498,8 +507,8 @@ For default URLs you can use the `default_url` plugin:
498
507
  class ImageUploader < Shrine
499
508
  plugin :default_url
500
509
 
501
- Attacher.default_url do |options|
502
- "/attachments/#{name}/default.jpg"
510
+ Attacher.default_url do |derivative: nil, **|
511
+ "/fallbacks/#{derivative || "original"}.jpg"
503
512
  end
504
513
  end
505
514
  ```
@@ -654,7 +663,7 @@ shows what are Shrine's equivalents.
654
663
 
655
664
  #### `root`, `base_path`, `permissions`, `directory_permissions`
656
665
 
657
- In Shrine these are configured on the FileSystem storage directly.
666
+ In Shrine these are configured on the `FileSystem` storage directly.
658
667
 
659
668
  #### `storage`, `storage_engines`
660
669
 
@@ -2,22 +2,25 @@
2
2
  title: The Design of Shrine
3
3
  ---
4
4
 
5
- *If you want an in-depth walkthrough through the Shrine codebase, see [Notes on study of shrine implementation] article by Jonathan Rochkind.*
5
+ *If you want an in-depth walkthrough through the Shrine codebase, see [Notes on
6
+ study of shrine implementation] article by Jonathan Rochkind.*
6
7
 
7
- There are five main types of objects that you deal with in Shrine:
8
+ There are five main types of classes that you deal with in Shrine:
8
9
 
9
- * Storage
10
- * `Shrine`
11
- * `Shrine::UploadedFile`
12
- * `Shrine::Attacher`
13
- * `Shrine::Attachment`
10
+ | Class | Description |
11
+ | :---- | :---------- |
12
+ | `Shrine::Storage::*` | Manages files on a particular storage service |
13
+ | `Shrine` | Wraps uploads and handles loading plugins |
14
+ | `Shrine::UploadedFile` | Represents a file uploaded to a storage |
15
+ | `Shrine::Attacher` | Handles file attachment logic |
16
+ | `Shrine::Attachment` | Provides convenience model attachment interface |
14
17
 
15
18
  ## Storage
16
19
 
17
20
  On the lowest level we have a storage. A storage class encapsulates file
18
21
  management logic on a particular service. It is what actually performs uploads,
19
22
  generation of URLs, deletions and similar. By convention it is namespaced under
20
- `Shrine::Storage`.
23
+ `Shrine::Storage::*`.
21
24
 
22
25
  ```rb
23
26
  filesystem = Shrine::Storage::FileSystem.new("uploads")
@@ -26,7 +29,7 @@ filesystem.url("foo") #=> "uploads/foo"
26
29
  filesystem.delete("foo")
27
30
  ```
28
31
 
29
- A storage is a PORO which responds to certain methods:
32
+ A storage is a PORO which implements the following interface:
30
33
 
31
34
  ```rb
32
35
  class Shrine
@@ -56,13 +59,14 @@ class Shrine
56
59
  end
57
60
  ```
58
61
 
59
- Storages are typically not used directly, but through `Shrine`.
62
+ Storages are typically not used directly, but through [`Shrine`](#shrine) and
63
+ [`Shrine::UploadedFile`](#shrine-uploadedfile) classes.
60
64
 
61
65
  ## `Shrine`
62
66
 
63
- A `Shrine` object (also called an "uploader") is essentially a wrapper around
64
- the `#upload` storage method. First the storage needs to be registered under a
65
- name:
67
+ The `Shrine` class (also called an "uploader") primarily provides a wrapper
68
+ method around `Storage#upload`. First, the storage needs to be registered under
69
+ a name:
66
70
 
67
71
  ```rb
68
72
  Shrine.storages[:disk] = Shrine::Storage::FileSystem.new("uploads")
@@ -72,7 +76,7 @@ Now we can upload files to the registered storage:
72
76
 
73
77
  ```rb
74
78
  uploaded_file = Shrine.upload(file, :disk)
75
- uploaded_file #=> #<Shrine::UploadedFile>
79
+ uploaded_file #=> #<Shrine::UploadedFile storage=:disk id="6a9fb596cc554efb" ...>
76
80
  ```
77
81
 
78
82
  The argument to `Shrine#upload` must be an IO-like object. The method does the
@@ -84,63 +88,97 @@ following:
84
88
  * closes the file
85
89
  * creates a `Shrine::UploadedFile` from the data
86
90
 
87
- `Shrine` class and subclasses are also used for loading plugins that extend all
88
- core classes. Each `Shrine` subclass has its own subclass of each of the core
89
- classes (`Shrine::UploadedFile`, `Shrine::Attacher`, and `Shrine::Attachment`),
90
- which makes it possible to have different `Shrine` subclasses with differently
91
- customized attachment logic. See [Creating a New Plugin] guide and the [Plugin
92
- system of Sequel and Roda] article for more details on the design of Shrine's
93
- plugin system.
91
+ ### Plugins
92
+
93
+ The `Shrine` class is also used for loading plugins, which provide additional
94
+ functionality by extending core classes.
95
+
96
+ ```rb
97
+ Shrine.plugin :derivatives
98
+
99
+ Shrine::UploadedFile.ancestors #=> [..., Shrine::Plugins::Derivatives::FileMethods, Shrine::UploadedFile::InstanceMethods, ...]
100
+ Shrine::Attacher.ancestors #=> [..., Shrine::Plugins::Derivatives::AttacherMethods, Shrine::Attacher::InstanceMethods, ...]
101
+ Shrine::Attachment.ancestors #=> [..., Shrine::Plugins::Derivatives::AttachmentMethods, Shrine::Attachment::InstanceMethods, ...]
102
+ ```
103
+
104
+ The plugins store their configuration in `Shrine.opts`:
105
+
106
+ ```rb
107
+ Shrine.plugin :derivation_endpoint, secret_key: "foo"
108
+ Shrine.plugin :default_storage, store: :other_store
109
+ Shrine.plugin :activerecord
110
+
111
+ Shrine.opts #=>
112
+ # { derivation_endpoint: { options: { secret_key: "foo" }, derivations: {} },
113
+ # default_storage: { store: :other_store },
114
+ # column: { serializer: Shrine::Plugins::Column::JsonSerializer },
115
+ # model: { cache: true },
116
+ # activerecord: { callbacks: true, validations: true } }
117
+ ```
118
+
119
+ Each `Shrine` subclass has its own copy of the core classes, storages and
120
+ options, which makes it possible to customize attachment logic per uploader.
121
+
122
+ ```rb
123
+ MyUploader = Class.new(Shrine)
124
+ MyUploader::UploadedFile.superclass #=> Shrine::UploadedFile
125
+ MyUploader::Attacher.superclass #=> Shrine::Attacher
126
+ MyUploader::Attachment.superclass #=> Shrine::Attachment
127
+ ```
128
+
129
+ See [Creating a New Plugin] guide and the [Plugin system of Sequel and Roda]
130
+ article for more details on the design of Shrine's plugin system.
94
131
 
95
132
  ## `Shrine::UploadedFile`
96
133
 
97
- `Shrine::UploadedFile` represents a file that was uploaded to a storage, and is
98
- the result of `Shrine#upload`. It is essentially a wrapper around a data hash
99
- containing information about the uploaded file.
134
+ A `Shrine::UploadedFile` object represents a file that was uploaded to a
135
+ storage, containing upload location, storage, and any metadata extracted during
136
+ the upload.
100
137
 
101
138
  ```rb
102
- uploaded_file #=> #<Shrine::UploadedFile>
103
- uploaded_file.data #=>
139
+ uploaded_file #=> #<Shrine::UploadedFile id="949sdjg834.jpg" storage=:store metadata={...}>
140
+
141
+ uploaded_file.id #=> "949sdjg834.jpg"
142
+ uploaded_file.storage_key #=> :store
143
+ uploaded_file.storage #=> #<Shrine::Storage::S3>
144
+ uploaded_file.metadata #=> {...}
145
+ ```
146
+
147
+ It has convenience methods for accessing metadata:
148
+
149
+ ```rb
150
+ uploaded_file.metadata #=>
104
151
  # {
105
- # "storage" => "file_system",
106
- # "id" => "9260ea09d8effd.pdf",
107
- # "metadata" => {
108
- # "filename" => "resume.pdf",
109
- # "mime_type" => "application/pdf",
110
- # "size" => 983294,
111
- # },
152
+ # "filename" => "matrix.mp4",
153
+ # "mime_type" => "video/mp4",
154
+ # "size" => 345993,
112
155
  # }
156
+
157
+ uploaded_file.original_filename #=> "matrix.mp4"
158
+ uploaded_file.extension #=> "mp4"
159
+ uploaded_file.mime_type #=> "video/mp4"
160
+ uploaded_file.size #=> 345993
113
161
  ```
114
162
 
115
- The data hash contains the storage the file was uploaded to, the location, and
116
- some metadata: original filename, MIME type and filesize. The
117
- `Shrine::UploadedFile` object has handy methods which use this data:
163
+ It also has methods that delegate to the storage:
118
164
 
119
165
  ```rb
120
- # metadata methods
121
- uploaded_file.original_filename
122
- uploaded_file.mime_type
123
- uploaded_file.size
124
- # ...
125
-
126
- # storage methods
127
- uploaded_file.url
128
- uploaded_file.exists?
129
- uploaded_file.open
130
- uploaded_file.download
131
- uploaded_file.delete
132
- # ...
166
+ uploaded_file.url #=> "https://my-bucket.s3.amazonaws.com/949sdjg834.jpg"
167
+ uploaded_file.open { |io| ... } # opens the uploaded file stream
168
+ uploaded_file.download { |file| ... } # downloads the uploaded file to disk
169
+ uploaded_file.stream(destination) # streams uploaded content into a writable destination
170
+ uploaded_file.exists? #=> true
171
+ uploaded_file.delete # deletes the uploaded file from the storage
133
172
  ```
134
173
 
135
- A `Shrine::UploadedFile` is itself an IO-like object (representing the
136
- remote file), so it can be passed to `Shrine#upload` as well.
174
+ A `Shrine::UploadedFile` is itself an IO-like object (built on top of
175
+ `Storage#open`), so it can be passed to `Shrine#upload` as well.
137
176
 
138
177
  ## `Shrine::Attacher`
139
178
 
140
179
  We usually want to treat uploaded files as *attachments* to records, saving
141
- their data into a database column. This is the responsibility of
142
- `Shrine::Attacher`. A `Shrine::Attacher` uses `Shrine` uploaders and
143
- `Shrine::UploadedFile` objects internally.
180
+ their data into a database column. This is done by `Shrine::Attacher`, which
181
+ internally uses `Shrine` and `Shrine::UploadedFile` classes.
144
182
 
145
183
  The attaching process requires a temporary and a permanent storage to be
146
184
  registered (by default that's `:cache` and `:store`):
@@ -152,40 +190,50 @@ Shrine.storages = {
152
190
  }
153
191
  ```
154
192
 
155
- A `Shrine::Attacher` is instantiated with a model instance and an attachment
156
- name (an "image" attachment will be saved to `image_data` field):
193
+ A `Shrine::Attacher` can be initialized standalone and handle the common
194
+ attachment flow, which includes dirty tracking (promoting cached file to
195
+ permanent storage, deleting previously attached file), validation, processing,
196
+ serialization etc.
197
+
198
+ ```rb
199
+ attacher = Shrine::Attacher.new
200
+
201
+ # ... user uploads a file ...
202
+
203
+ attacher.assign(io) # uploads to temporary storage
204
+ attacher.file #=> #<Shrine::UploadedFile storage=:cache ...>
205
+
206
+ # ... handle file validations ...
207
+
208
+ attacher.finalize # uploads to permanent storage
209
+ attacher.file #=> #<Shrine::UploadedFile storage=:store ...>
210
+ ```
211
+
212
+ It can also be initialized with a model instance to handle serialization into a
213
+ model attribute:
157
214
 
158
215
  ```rb
159
216
  attacher = Shrine::Attacher.from_model(photo, :image)
160
217
 
161
218
  attacher.assign(file)
162
- attacher.file #=> #<Shrine::UploadedFile storage=:cache ...>
163
- attacher.record.image_data #=> "{\"storage\":\"cache\",\"id\":\"9260ea09d8effd.jpg\",\"metadata\":{...}}"
219
+ photo.image_data #=> "{\"storage\":\"cache\",\"id\":\"9260ea09d8effd.jpg\",\"metadata\":{...}}"
164
220
 
165
221
  attacher.finalize
166
- attacher.file #=> #<Shrine::UploadedFile storage=:store ...>
167
- attacher.record.image_data #=> "{\"storage\":\"store\",\"id\":\"ksdf02lr9sf3la.jpg\",\"metadata\":{...}}"
222
+ photo.image_data #=> "{\"storage\":\"store\",\"id\":\"ksdf02lr9sf3la.jpg\",\"metadata\":{...}}"
168
223
  ```
169
224
 
170
- Above a file is assigned by the attacher, which "caches" (uploads) the file to
171
- the temporary storage. The cached file is then "promoted" (uploaded) to
172
- permanent storage. Behind the scenes a cached `Shrine::UploadedFile` is given
173
- to `Shrine#upload`, which works because `Shrine::UploadedFile` is an IO-like
174
- object. After both caching and promoting the data hash of the uploaded file is
175
- assigned to the record's column as JSON.
176
-
177
- For more details see [Using Attacher].
225
+ For more details, see the [Using Attacher] guide and
226
+ [`entity`][entity]/[`model`][model] plugins.
178
227
 
179
228
  ## `Shrine::Attachment`
180
229
 
181
- `Shrine::Attachment` is the highest level of abstraction. A
182
- `Shrine::Attachment` module exposes the `Shrine::Attacher` object through the
183
- model instance. The `Shrine::Attachment` class is a sublcass of `Module`, which
184
- means that an instance of `Shrine::Attachment` is a module:
230
+ A `Shrine::Attachment` module provides a convenience model interface around the
231
+ `Shrine::Attacher` object. The `Shrine::Attachment` class is a subclass of
232
+ `Module`, which means that an instance of `Shrine::Attachment` is a module:
185
233
 
186
234
  ```rb
187
235
  Shrine::Attachment.new(:image).is_a?(Module) #=> true
188
- Shrine::Attachment.new(:image).instance_methods #=> [:image=, :image, :image_url, :image_attacher]
236
+ Shrine::Attachment.new(:image).instance_methods #=> [:image=, :image, :image_url, :image_attacher, ...]
189
237
 
190
238
  # equivalents
191
239
  Shrine::Attachment.new(:image)
@@ -193,30 +241,31 @@ Shrine::Attachment[:image]
193
241
  Shrine::Attachment(:image)
194
242
  ```
195
243
 
196
- We can include this module to a model:
244
+ We can include this module into a model:
197
245
 
198
246
  ```rb
199
- class Photo
200
- include Shrine::Attachment(:image)
201
- end
247
+ Photo.include Shrine::Attachment(:image)
202
248
  ```
203
249
  ```rb
204
- photo.image = file # shorthand for `photo.image_attacher.assign(file)`
205
- photo.image # shorthand for `photo.image_attacher.get`
206
- photo.image_url # shorthand for `photo.image_attacher.url`
250
+ photo.image = file # shorthand for `photo.image_attacher.assign(file)`
251
+ photo.image # shorthand for `photo.image_attacher.get`
252
+ photo.image_url # shorthand for `photo.image_attacher.url`
207
253
 
208
- photo.image_attacher #=> #<Shrine::Attacher>
254
+ photo.image_attacher #=> #<Shrine::Attacher @cache_key=:cache @store_key=:store ...>
209
255
  ```
210
256
 
211
- When a persistence plugin is loaded, the `Shrine::Attachment` module also
212
- automatically:
257
+ When a persistence plugin is loaded ([`activerecord`][activerecord],
258
+ [`sequel`][sequel]), the `Shrine::Attachment` module also automatically:
213
259
 
214
260
  * syncs Shrine's validation errors with the record
215
261
  * triggers promoting after record is saved
216
- * deletes the uploaded file if attachment was replaced/removed or the record
217
- destroyed
262
+ * deletes the uploaded file if attachment was replaced or the record destroyed
218
263
 
219
264
  [Using Attacher]: https://shrinerb.com/docs/attacher
220
265
  [Notes on study of shrine implementation]: https://bibwild.wordpress.com/2018/09/12/notes-on-study-of-shrine-implementation/
221
266
  [Creating a New Plugin]: https://shrinerb.com/docs/creating-plugins
222
267
  [Plugin system of Sequel and Roda]: https://twin.github.io/the-plugin-system-of-sequel-and-roda/
268
+ [entity]: https://shrinerb.com/docs/plugins/entity
269
+ [model]: https://shrinerb.com/docs/plugins/model
270
+ [activerecord]: https://shrinerb.com/docs/plugins/activerecord
271
+ [sequel]: https://shrinerb.com/docs/plugins/sequel
@@ -17,6 +17,7 @@ title: Articles
17
17
  * [Better File Uploads with Shrine: Processing](https://twin.github.io/better-file-uploads-with-shrine-processing/)
18
18
  * [Better File Uploads with Shrine: Metadata](https://twin.github.io/better-file-uploads-with-shrine-metadata/)
19
19
  * [Better File Uploads with Shrine: Direct Uploads](https://twin.github.io/better-file-uploads-with-shrine-direct-uploads)
20
+ * [Upcoming Features in Shrine 3.0](https://twin.github.io/upcoming-features-in-shrine-3-0/)
20
21
  * [Shrine 3.0 Released](https://twin.github.io/shrine-3-0-released/)
21
22
 
22
23
  ## Other Articles