lato_cms 3.2.6 → 3.3.1

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: cdc7dae92de55a7a6e1e1290ee957bbf0b63fdf16404524c3f1803bd68123d0e
4
- data.tar.gz: 1bc853f4846e25c41db7bddc0d53ac55f05699a06cf97a221abf6b7573458a7e
3
+ metadata.gz: 9a5b87d2c477cafb579b7ceea01c41e72071b3a789f425e184acbe99592f63ab
4
+ data.tar.gz: 123893a3b2a7c226c4910f41ab931b5bf7fd28b30b883293d811e6fc439d0155
5
5
  SHA512:
6
- metadata.gz: 0117c7af5986170ce154bee821263a5ac8da6759d71a8fd682624e59b84d31a4f368d15d8c12d05549cd38d08baee38aa5a09550cad85a2bfaf9e30071baa50a
7
- data.tar.gz: f18ef5c8dcaf4c2a3e2cd66e031bd2853eca12e69fc9aab6afcbe93989a90de362948317a526a45c08fe0ba38a647220505f95282e7e2b1910bc32c864386ca0
6
+ metadata.gz: 23968a738f1d1295e172d546617950e2b49c900b45ac6399080a01380c97ae7ebba48414277c58565205f8229dd758b798a286a3dfb6eac55d3c1091258fad12
7
+ data.tar.gz: 0a3d712b435da91e70a4a90edd8cefad81fa8105dface4946c4776490b920d3f2259c4857619d99af374e84b181aea38bc0524b2df1ef6f22532f80a141d16c5
data/README.md CHANGED
@@ -43,6 +43,39 @@ import "lato_cms/application";
43
43
  // ....
44
44
  ```
45
45
 
46
+ ## Media URLs
47
+
48
+ By default a media URL is an Active Storage **redirect** URL: Rails answers 302 with a
49
+ signed address that expires. On a remote service (S3 and friends) that is what you want —
50
+ the file then comes from the service itself.
51
+
52
+ On a **disk** service it is the wrong trade: the redirect costs a second request, and its
53
+ target is served `private, must-revalidate`, so no browser, proxy or CDN can keep it. A
54
+ video of a few dozen MB is then downloaded again on every visit, from a thread of your app
55
+ server. Switch the mode:
56
+
57
+ ```ruby
58
+ LatoCms.configure do |config|
59
+ config.media_url_mode = :proxy
60
+ end
61
+ ```
62
+
63
+ Media URLs (the blob and every variant) then point at Active Storage's proxy controller:
64
+ no redirect, and `Cache-Control: public, immutable`, which a cache in front of the app can
65
+ actually use. The address carries the signature of the blob, so a replaced file is a
66
+ different address and can never be served stale.
67
+
68
+ ⚠️ **`:proxy` needs something in front that splits byte ranges** — nginx with `slice`,
69
+ or a CDN that does the same. Active Storage's proxy controller serves one range at a
70
+ time, holding it in memory, and answers **416** to anything above
71
+ `streaming_chunk_max_size` (100 MB by default). A `<video>` opens with `Range: bytes=0-`,
72
+ that is the whole file: above that size the browser reads the 416 as "format not
73
+ supported", and below it the app loads tens of MB into memory per request. With slices of
74
+ 1 MB neither happens. Without such a front, stay on `:redirect`.
75
+
76
+ Both modes ask for an `inline` disposition: a media of the library is content to show, and
77
+ the default for a video would otherwise turn opening its address into a download.
78
+
46
79
  ## Development
47
80
 
48
81
  Clone repository, install dependencies, run migrations and start:
@@ -81,16 +81,17 @@ module LatoCms
81
81
  end
82
82
  end
83
83
 
84
- # Regenerates one translatable attribute (alt_text or title, see the
85
- # route constraint) with the LLM. Runs as a Lato::Operation (see
86
- # GenerateMediaTextJob) rather than inline: an LLM call can take a while,
87
- # and blocking the request risks timing it out. The admin instead lands on
88
- # a live progress page.
84
+ # Regenerates the translatable attributes (alt_text and title, or a single
85
+ # one — see the route constraint) with the LLM. Runs as a Lato::Operation
86
+ # (see GenerateMediaTextJob) rather than inline: an LLM call can take a
87
+ # while, and blocking the request risks timing it out. The admin instead
88
+ # lands on a live progress page.
89
89
  def regenerate_text_action
90
90
  @media = query_media.find(params[:id])
91
- attribute = params[:attribute].to_s
91
+ attributes = params[:attribute] == "all" ? LatoCms.config.llm_media_attributes : [params[:attribute]]
92
+ attributes = attributes.map(&:to_s).select { |attribute| LatoCms.config.llm_generates?(attribute) }
92
93
 
93
- unless @media.image? && LatoCms.config.llm_generates?(attribute)
94
+ unless @media.image? && attributes.any?
94
95
  respond_to do |format|
95
96
  message = t("lato_cms.media_text_regenerate_unavailable")
96
97
  format.html { redirect_to lato_cms.media_update_path(@media), alert: message }
@@ -99,7 +100,7 @@ module LatoCms
99
100
  return
100
101
  end
101
102
 
102
- operation = Lato::Operation.generate("LatoCms::GenerateMediaTextJob", { media_id: @media.id, attributes: [attribute] }, @session.user_id)
103
+ operation = Lato::Operation.generate("LatoCms::GenerateMediaTextJob", { media_id: @media.id, attributes: attributes }, @session.user_id)
103
104
 
104
105
  if operation.start
105
106
  redirect_to lato.operation_path(operation)
@@ -1,9 +1,10 @@
1
1
  module LatoCms
2
2
  # Generates alt text and/or title for an image Media via an OpenAI-compatible
3
- # LLM, one LLM call per attribute (each has its own prompt, see
4
- # LatoCms::Config#llm_prompt). `attributes` defaults to every attribute
5
- # enabled in config (the automatic post-upload run); the manual "Regenerate
6
- # with AI" buttons pass the single attribute they sit next to.
3
+ # LLM in a single call, whatever the number of attributes: the image travels
4
+ # in the request, so one call per attribute meant uploading it twice (see
5
+ # Media#generate_texts!). `attributes` defaults to every attribute enabled in
6
+ # config, which is what both the automatic post-upload run and the
7
+ # "Regenerate with AI" action ask for.
7
8
  #
8
9
  # Doubles as a plain background job (post-upload call site, see
9
10
  # Media#enqueue_text_generation: fire-and-forget, best effort, never blocks
@@ -19,7 +20,7 @@ module LatoCms
19
20
  return unless media
20
21
 
21
22
  attributes = Array(params[:attributes]).presence || LatoCms.config.llm_media_attributes
22
- attributes.each { |attribute| media.generate_text!(attribute, raise_on_error: operation?) }
23
+ media.generate_texts!(attributes, raise_on_error: operation?)
23
24
  save_operation_output_message(I18n.t("lato_cms.media_text_regenerated")) if operation?
24
25
  end
25
26
  end
@@ -154,11 +154,27 @@ module LatoCms
154
154
  end
155
155
 
156
156
  def url
157
- Rails.application.routes.url_helpers.rails_blob_path(file, only_path: true) if file.attached?
157
+ attachment_url(file) if file.attached?
158
158
  end
159
159
 
160
160
  def poster_url
161
- Rails.application.routes.url_helpers.rails_blob_path(poster_file, only_path: true) if poster_file.attached?
161
+ attachment_url(poster_file) if poster_file.attached?
162
+ end
163
+
164
+ # The URL of an attachment or of one of its variants, in the mode the host
165
+ # app asked for (see LatoCms::Config::MEDIA_URL_MODES).
166
+ #
167
+ # `inline` in both modes: a media of the library is content to show — a
168
+ # picture in a page, a video in a player — and the default disposition of a
169
+ # video is `attachment`, which turns opening its address into a download.
170
+ # Whoever wants a download asks for it explicitly, as the panel does.
171
+ def attachment_url(attachable)
172
+ helpers = Rails.application.routes.url_helpers
173
+ if LatoCms.config.media_url_proxy?
174
+ helpers.rails_storage_proxy_path(attachable, only_path: true, disposition: :inline)
175
+ else
176
+ helpers.rails_blob_path(attachable, only_path: true, disposition: :inline)
177
+ end
162
178
  end
163
179
 
164
180
  # Swaps the underlying file while keeping the same record, so every field
@@ -196,50 +212,68 @@ module LatoCms
196
212
  Rails.logger.warn("LatoCms: failed to generate video poster for media #{id}: #{e.message}")
197
213
  end
198
214
 
199
- # Asks the configured OpenAI-compatible LLM for `attribute` (alt_text or
200
- # title) in every configured locale and merges it in (existing
201
- # translations for locales the LLM didn't return are kept, the rest are
202
- # replaced individually). No-op unless the LLM is configured and enabled
203
- # for that attribute (see LatoCms::Config#llm_generates?) and this media
204
- # is an image, since the LLM has to look at the file.
215
+ # Asks the configured OpenAI-compatible LLM for the given attributes
216
+ # (alt_text, title, or both) in every configured locale and merges the
217
+ # result in — existing translations for locales the LLM didn't return are
218
+ # kept, the rest are replaced individually. Attributes switched off in
219
+ # config are skipped; no-op unless the LLM is configured and this media is
220
+ # an image, since the model has to look at the file.
221
+ #
222
+ # All the requested attributes travel in ONE request: the image is the
223
+ # expensive part of the payload (it goes as a base64 data URI, see
224
+ # request_completion), so asking per attribute uploaded the same file twice
225
+ # and paid for it twice.
205
226
  #
206
227
  # Best effort by default (`raise_on_error: false`): any failure is logged
207
228
  # and swallowed, since the automatic post-upload call site (see
208
229
  # GenerateMediaTextJob) must never break the upload over a flaky LLM. The
209
- # manual "Regenerate with AI" actions run as a Lato::Operation the admin
210
- # is actively watching, so they pass `raise_on_error: true` to have
211
- # failures surface there instead of disappearing silently.
212
- def generate_text!(attribute, raise_on_error: false)
213
- attribute = attribute.to_s
214
- raise ArgumentError, "unknown translatable attribute: #{attribute}" unless TRANSLATABLE_ATTRIBUTES.include?(attribute)
215
- return unless image? && file.attached? && LatoCms.config.llm_generates?(attribute)
230
+ # manual "Regenerate with AI" action runs as a Lato::Operation the admin is
231
+ # actively watching, so it passes `raise_on_error: true` to have failures
232
+ # surface there instead of disappearing silently.
233
+ def generate_texts!(attributes = LatoCms.config.llm_media_attributes, raise_on_error: false)
234
+ attributes = Array(attributes).map(&:to_s)
235
+ unknown = attributes - TRANSLATABLE_ATTRIBUTES
236
+ raise ArgumentError, "unknown translatable attribute: #{unknown.join(", ")}" if unknown.any?
237
+
238
+ attributes = attributes.select { |attribute| LatoCms.config.llm_generates?(attribute) }
239
+ return if attributes.empty? || !image? || !file.attached?
216
240
 
217
241
  begin
218
- translations = parse_translations(request_completion(llm_prompt(attribute)))
219
- raise "The LLM returned no usable #{attribute.humanize.downcase}" if translations.blank?
242
+ generated = parse_translations(request_completion(llm_prompt(*attributes)), attributes)
243
+ raise "The LLM returned no usable #{attributes.to_sentence}" if generated.blank?
220
244
 
221
- public_send("#{attribute}_translations=", translations_of(attribute).merge(translations))
245
+ generated.each { |attribute, translations| public_send("#{attribute}_translations=", translations_of(attribute).merge(translations)) }
222
246
  save!
223
247
  rescue StandardError => e
224
- Rails.logger.warn("LatoCms: failed to generate #{attribute} for media #{id}: #{e.message}")
248
+ Rails.logger.warn("LatoCms: failed to generate #{attributes.join(", ")} for media #{id}: #{e.message}")
225
249
  raise if raise_on_error
226
250
  end
227
251
  end
228
252
 
229
- # Full prompt sent for `attribute`: the custom-or-default task prompt (see
230
- # LatoCms::Config#llm_prompt) with {languages}/{urls} substituted, plus the
231
- # engine's fixed response-format contract, appended here rather than left
232
- # to the prompt so parsing never depends on how a custom prompt is worded.
233
- def llm_prompt(attribute)
253
+ def generate_text!(attribute, raise_on_error: false)
254
+ generate_texts!([attribute], raise_on_error: raise_on_error)
255
+ end
256
+
257
+ # Full prompt sent for `attributes`: one custom-or-default task prompt per
258
+ # attribute (see LatoCms::Config#llm_prompt) with {languages}/{urls}
259
+ # substituted, plus the engine's fixed response-format contract, appended
260
+ # here rather than left to the prompts so parsing never depends on how a
261
+ # custom one is worded.
262
+ def llm_prompt(*attributes)
263
+ attributes = attributes.flatten.map(&:to_s).presence || LatoCms.config.llm_media_attributes.map(&:to_s)
234
264
  variables = {
235
265
  "languages" => llm_locales.join(", "),
236
266
  "urls" => usage_urls.presence&.join(", ") || "none"
237
267
  }
238
268
  placeholders = /\{(#{LatoCms::Config::LLM_PROMPT_VARIABLES.join("|")})\}/
239
- task = LatoCms.config.llm_prompt(attribute).gsub(placeholders) { variables[Regexp.last_match(1)] }
269
+ tasks = attributes.map do |attribute|
270
+ task = LatoCms.config.llm_prompt(attribute).gsub(placeholders) { variables[Regexp.last_match(1)] }
271
+ "#{attribute}: #{task}"
272
+ end
240
273
 
241
- "#{task}\n\nRespond with a single JSON object only, no markdown, no extra text, with exactly these " \
242
- "keys: #{llm_locales.join(", ")}. Each value is the text written in that language."
274
+ "#{tasks.join("\n\n")}\n\nRespond with a single JSON object only, no markdown, no extra text, with exactly " \
275
+ "these keys: #{attributes.join(", ")}. Each value is an object with exactly these keys: " \
276
+ "#{llm_locales.join(", ")}, each holding the text written in that language."
243
277
  end
244
278
 
245
279
  # Fixed small variant used across admin UI (Media index, picker grid, field
@@ -248,9 +282,7 @@ module LatoCms
248
282
  def thumbnail_url
249
283
  return nil unless image? && file.attached? && file.variable?
250
284
 
251
- Rails.application.routes.url_helpers.rails_representation_path(
252
- file.variant(resize_to_fill: [200, 200]), only_path: true
253
- )
285
+ attachment_url(file.variant(resize_to_fill: [200, 200]))
254
286
  rescue StandardError => e
255
287
  Rails.logger.error("LatoCms: Failed to build thumbnail for media #{id}: #{e.message}")
256
288
  nil
@@ -263,9 +295,7 @@ module LatoCms
263
295
  return nil unless image? && file.attached?
264
296
  return url unless file.variable?
265
297
 
266
- Rails.application.routes.url_helpers.rails_representation_path(
267
- file.variant(resize_to_limit: [1200, 1200]), only_path: true
268
- )
298
+ attachment_url(file.variant(resize_to_limit: [1200, 1200]))
269
299
  rescue StandardError => e
270
300
  Rails.logger.error("LatoCms: Failed to build preview for media #{id}: #{e.message}")
271
301
  url
@@ -278,12 +308,11 @@ module LatoCms
278
308
  def variant_urls(sizes_config)
279
309
  return {} if sizes_config.blank? || !sizes_config.respond_to?(:each_pair) || !file.attached? || !file.variable?
280
310
 
281
- url_helpers = Rails.application.routes.url_helpers
282
311
  sizes_config.each_with_object({}) do |(name, opts), acc|
283
312
  transformation = self.class.variant_transformation(opts)
284
313
  next if transformation.blank?
285
314
 
286
- acc[name] = url_helpers.rails_representation_path(file.variant(transformation), only_path: true)
315
+ acc[name] = attachment_url(file.variant(transformation))
287
316
  end
288
317
  rescue StandardError => e
289
318
  Rails.logger.error("LatoCms: Failed to build image variants for media #{id}: #{e.message}")
@@ -354,11 +383,18 @@ module LatoCms
354
383
  }])
355
384
  end
356
385
 
357
- def parse_translations(content)
386
+ # { attribute => { locale => text } }, keeping only what was asked for and
387
+ # what the app is configured to speak.
388
+ def parse_translations(content, attributes)
358
389
  return {} if content.blank?
359
390
 
360
391
  parsed = JSON.parse(content[/\{.*\}/m] || content)
361
- parsed.slice(*llm_locales).transform_values(&:to_s)
392
+ parsed.slice(*attributes).each_with_object({}) do |(attribute, translations), result|
393
+ next unless translations.is_a?(Hash)
394
+
395
+ translations = translations.slice(*llm_locales).transform_values(&:to_s).compact_blank
396
+ result[attribute] = translations if translations.any?
397
+ end
362
398
  rescue JSON::ParserError
363
399
  {}
364
400
  end
@@ -33,6 +33,24 @@
33
33
  <%= lato_form_item_input_text form, :name, required: true %>
34
34
  </div>
35
35
 
36
+ <%# One action for every attribute the LLM is enabled for: the image
37
+ travels in the request, so asking per attribute uploaded the same
38
+ file once per attribute. %>
39
+ <% llm_attributes = media.image? ? LatoCms.config.llm_media_attributes : [] %>
40
+ <% if llm_attributes.any? %>
41
+ <div class="d-flex justify-content-end mb-2">
42
+ <%= link_to t("lato_cms.media_text_regenerate"), lato_cms.media_regenerate_text_action_path(media, attribute: "all"),
43
+ class: "btn btn-sm btn-outline-secondary",
44
+ data: {
45
+ lato_action_target: "trigger",
46
+ turbo_method: :post,
47
+ turbo_frame: "lato_operation",
48
+ turbo_confirm: t("lato_cms.media_text_regenerate_confirm", fields: llm_attributes.map { |attribute| t("lato_cms.media_#{attribute}").downcase }.join(", ")),
49
+ action_title: t("lato_cms.media_text_regenerate")
50
+ } %>
51
+ </div>
52
+ <% end %>
53
+
36
54
  <% if media.image? %>
37
55
  <%= render "lato_cms/media/translations_field", form: form, media: media, attribute: "alt_text" %>
38
56
  <% end %>
@@ -1,24 +1,10 @@
1
1
  <%# One tab per configured locale for a translatable Media attribute
2
- (alt_text/title), with its own "Regenerate with AI" button when the LLM
3
- is configured and enabled for that attribute. Images only: the LLM has
4
- to look at the file to describe it. %>
2
+ (alt_text/title). The "Regenerate with AI" action is not here: it covers
3
+ every enabled attribute in one LLM call (see _form_update). %>
5
4
  <% label = t("lato_cms.media_#{attribute}") %>
6
5
 
7
6
  <div class="mb-3">
8
- <div class="d-flex align-items-center justify-content-between">
9
- <label class="form-label mb-0"><%= label %></label>
10
- <% if media.image? && LatoCms.config.llm_generates?(attribute) %>
11
- <%= link_to t("lato_cms.media_text_regenerate"), lato_cms.media_regenerate_text_action_path(media, attribute: attribute),
12
- class: "btn btn-sm btn-outline-secondary",
13
- data: {
14
- lato_action_target: "trigger",
15
- turbo_method: :post,
16
- turbo_frame: "lato_operation",
17
- turbo_confirm: t("lato_cms.media_text_regenerate_confirm", field: label.downcase),
18
- action_title: "#{t('lato_cms.media_text_regenerate')} - #{label}"
19
- } %>
20
- <% end %>
21
- </div>
7
+ <label class="form-label"><%= label %></label>
22
8
 
23
9
  <ul class="nav nav-tabs">
24
10
  <% LatoCms.config.locales.each_with_index do |locale, index| %>
@@ -105,7 +105,7 @@ en:
105
105
  media_alt_text: Alt text
106
106
  media_title: Title
107
107
  media_text_regenerate: Regenerate with AI
108
- media_text_regenerate_confirm: "This will overwrite the current %{field} in every language with a new AI-generated one. Continue?"
108
+ media_text_regenerate_confirm: "This will overwrite the current values of %{fields} in every language with AI-generated ones. Continue?"
109
109
  media_text_regenerated: Text regenerated
110
110
  media_text_regenerate_unavailable: AI generation is only available for images, and only when an LLM is configured and enabled for this field
111
111
  media_text_regenerate_failed: Failed to start AI generation
@@ -105,7 +105,7 @@ it:
105
105
  media_alt_text: Testo alternativo
106
106
  media_title: Titolo
107
107
  media_text_regenerate: Rigenera con AI
108
- media_text_regenerate_confirm: "Il %{field} attuale in ogni lingua verrà sostituito con uno generato dall'AI. Continuare?"
108
+ media_text_regenerate_confirm: "I valori attuali di %{fields} in ogni lingua verranno sostituiti con testi generati dall'AI. Continuare?"
109
109
  media_text_regenerated: Testo rigenerato
110
110
  media_text_regenerate_unavailable: La generazione AI è disponibile solo per le immagini e solo se un LLM è configurato e abilitato per questo campo
111
111
  media_text_regenerate_failed: Impossibile avviare la generazione AI
data/config/routes.rb CHANGED
@@ -34,7 +34,7 @@ LatoCms::Engine.routes.draw do
34
34
  get ':id/update', to: 'media#update', as: :media_update
35
35
  patch ':id/update', to: 'media#update_action', as: :media_update_action
36
36
  patch ':id/replace-file', to: 'media#replace_file_action', as: :media_replace_file_action
37
- post ':id/regenerate/:attribute', to: 'media#regenerate_text_action', as: :media_regenerate_text_action, constraints: { attribute: /alt_text|title/ }
37
+ post ':id/regenerate/:attribute', to: 'media#regenerate_text_action', as: :media_regenerate_text_action, constraints: { attribute: /alt_text|title|all/ }
38
38
  delete ':id', to: 'media#destroy_action', as: :media_destroy_action
39
39
  end
40
40
  end
@@ -3,8 +3,19 @@ module LatoCms
3
3
  # This class contains the default configuration of the engine.
4
4
  ##
5
5
  class Config
6
- attr_accessor :locales, :templates_path, :admin_roles, :llm_api_url, :llm_model, :llm_api_key,
7
- :llm_generate_alt_text, :llm_generate_title, :llm_alt_text_prompt, :llm_title_prompt
6
+ attr_accessor :locales, :templates_path, :admin_roles, :media_url_mode, :llm_api_url, :llm_model,
7
+ :llm_api_key, :llm_generate_alt_text, :llm_generate_title, :llm_alt_text_prompt,
8
+ :llm_title_prompt
9
+
10
+ # How the URLs of media files are built (see LatoCms::Media#url).
11
+ # - :redirect — Rails answers 302 with a signed URL that expires; on a
12
+ # remote service (S3 and friends) the file then comes from the service
13
+ # itself, which is what you want there.
14
+ # - :proxy — the file is served through the app, with no redirect and with
15
+ # `Cache-Control: public, immutable`, so browsers and any cache in front
16
+ # can keep it. On a disk service this is strictly better: the redirect
17
+ # costs a second request and its target can be cached by nobody.
18
+ MEDIA_URL_MODES = %i[redirect proxy].freeze
8
19
 
9
20
  # Placeholders substituted at request time in both default and custom LLM
10
21
  # prompts (see LatoCms::Media#llm_prompt):
@@ -37,6 +48,11 @@ module LatoCms
37
48
  # (create, update, delete) and translation links.
38
49
  @admin_roles = { none: 0, operator: 1, admin: 2 }
39
50
 
51
+ # Default is :redirect, which is how it always worked: switching the way
52
+ # every media URL is built is a decision for the host app, not something
53
+ # an upgrade should do on its own. See MEDIA_URL_MODES.
54
+ @media_url_mode = :redirect
55
+
40
56
  # Optional OpenAI-compatible endpoint used to auto-generate alt text and
41
57
  # title for uploaded images (see LatoCms::Media#generate_text!). All
42
58
  # three must be set for the feature to activate; unset by default.
@@ -56,6 +72,10 @@ module LatoCms
56
72
  @llm_title_prompt = nil
57
73
  end
58
74
 
75
+ def media_url_proxy?
76
+ media_url_mode.to_sym == :proxy
77
+ end
78
+
59
79
  def llm_configured?
60
80
  llm_api_url.present? && llm_model.present? && llm_api_key.present?
61
81
  end
@@ -1,3 +1,3 @@
1
1
  module LatoCms
2
- VERSION = "3.2.6"
2
+ VERSION = "3.3.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lato_cms
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.2.6
4
+ version: 3.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gregorio Galante