Lato CMS
How to use Lato CMS
lato_cms adds content management to a Lato admin panel.
Use it when admins need to create pages and edit structured content without changing application code.
Lato CMS works with Lato Spaces, so pages can be scoped to the selected group.
Installation
Add Lato CMS to the application Gemfile:
gem "lato"
gem "lato_spaces"
gem "lato_cms"
Install the engine and run migrations:
bundle
rails lato_cms:install:application
rails lato_cms:install:migrations
rails db:migrate
Mount the engine in config/routes.rb:
Rails.application.routes.draw do
mount LatoCms::Engine => "/lato-cms"
# ...
end
Import styles in app/assets/stylesheets/application.scss:
@import "lato_cms/application";
Import JavaScript in app/javascript/application.js:
import "lato_cms/application"
Permissions
Users must be logged into Lato, must have a selected Lato Spaces group, and must have Lato CMS admin permission enabled.
user = Lato::User.find_by(email: "admin@example.com")
user.update!(lato_cms_admin_role: 1)
Configuration
Configure available locales and template directory:
LatoCms.configure do |config|
config.locales = [:en, :it]
config.templates_path = "config/lato_cms"
end
Templates and components
Templates define which content blocks are available on a page. Components define the fields inside those blocks. Both are YAML files stored in the application. Template components are enabled by default, and each page can turn optional components on or off from the admin panel.
config/lato_cms/
templates/
homepage.yml
blog_post.yml
components/
hero.yml
content.yml
Generate new files with Rails tasks:
rails "lato_cms:generate:template[blog_post]"
rails "lato_cms:generate:component[hero,title:string,subtitle:textarea,image:file]"
Template example
# config/lato_cms/templates/homepage.yml
id: homepage
name: Homepage
components:
hero_section:
component_id: hero
name: Hero Section
required: true
content_section:
component_id: content
Optional and required components
Components listed in a template are optional unless required: true is set.
Optional components show an enabled switch in the page editor. Admins can disable them for a specific page
without removing the component from the template.
# config/lato_cms/templates/homepage.yml
id: homepage
name: Homepage
components:
hero_section:
component_id: hero
name: Hero Section
required: true
banner_section:
component_id: banner
name: Promotional Banner
content_section:
component_id: content
name: Content
In this example hero_section is always active and cannot be disabled.
banner_section and content_section can be enabled or disabled per page.
Disabled components are hidden in the editor, cannot be saved, and are not exposed when page fields are returned by the API.
Repeater components
Add repeater: true to a template component when admins need to create multiple items with the same field set.
The API returns repeater fields as an ordered array of items.
# config/lato_cms/templates/homepage.yml
components:
feature_cards:
component_id: feature_card
name: Feature Cards
repeater: true
settings:
min: 1
# config/lato_cms/components/feature_card.yml
id: feature_card
name: Feature Card
fields:
title:
name: Title
type: string
required: true
description:
name: Description
type: textarea
Component example
# config/lato_cms/components/hero.yml
id: hero
name: Hero
fields:
title:
name: Title
type: string
required: true
subtitle:
name: Subtitle
type: textarea
image:
name: Image
type: file
settings:
accept: "image/*"
attachments:
name: Attachments
type: file
settings:
multiple: true
Supported field types
| Type | Use for |
|---|---|
string |
Short text. |
textarea |
Multi-line plain text. |
text |
Long rich text or HTML content. |
number |
Numeric values. |
date / datetime
|
Date and time values. |
boolean |
True/false values. |
select / multiselect
|
One or more values from predefined options. |
color |
Color values. |
json |
Structured data. |
file |
One or more files picked from the media library. Set settings.multiple: true to allow more than one. |
image |
Single image picked from the media library. Optionally generates resized variants (see Image sizes). |
video |
Single video picked from the media library. Its poster image is generated once per media item (see Video poster). |
gallery |
Multiple sortable images picked from the media library. Optionally generates resized variants (see Image sizes). |
custom |
Application-defined field rendered with a custom partial. |
Any field can declare required: true. For attachment fields
(file, image, video, gallery) this means the
field must keep at least one file: the editor blocks saving when the field would end up empty,
and the server rejects the save with an error otherwise.
Media library
file, image, video, and gallery fields don't upload
straight into the field: admins pick from a shared Media library (Media in the sidebar),
scoped to the current Lato Spaces group like pages. Each field opens a picker overlay to search the
existing library or drag & drop a new file; the same media item can be reused across any number of
fields and pages.
A media item's name, alt text and title are edited from the library's own edit form, not from the
upload dialog — on upload, name defaults to the filename.
Deleting a media item still referenced by a field is blocked until it's removed from every field using it.
Uploads (from the library and from the picker's Upload new tab alike) show a progress bar with the real upload percentage, so large videos don't look like a frozen page.
Where a media item is used
The library index shows how many page fields reference each media item, and filters list only the unused ones (the only ones that can be deleted — the delete action is inert while a media is still referenced) or those still missing an alt text or a title in at least one configured locale (alt text only applies to images; a title is editable on any media type). Each media item also has a detail page: a read-only summary (preview, name, alt text and title per locale) plus the list of pages using it, with a link to each one and the fields involved.
Replacing a file
The edit form of a media item has a second tab, Replace file (admins only): it uploads a new file over the
existing media item, keeping the same record, so every page and field already referencing it shows the new
file. That's the point of the action — swapping a logo everywhere at once — and its danger, which
is why the form states how many fields are affected and asks for a confirmation. The previous file is deleted,
and media_type is re-inferred from the new one. Metadata (name, alt text, title) is kept: to change
it, use the Details tab. Uploading a file from the library's Upload action, as before, always creates a
separate media item instead.
Alt text and title are translatable: alt text is only shown for image media, title for every media type,
and the edit form has one tab per locale configured in LatoCms.config.locales for each.
Each locale's text is independent — leaving one blank doesn't affect the others.
image = LatoCms::Media.find(12)
image.alt_text(:en) # => "A dog running on the beach"
image.alt_text(:it) # => nil, if the Italian tab was left blank
image.alt_text_translations # => { "en" => "A dog running on the beach" }
image.title(:en) # => "Dog on the beach"
image.title_translations # => { "en" => "Dog on the beach", "it" => "Cane in spiaggia" }
image.alt_text / image.title without an explicit locale resolve against
I18n.locale. When a media item is read off a page's field (page_field.attachments
in the JSON API, as alt_text and title), both resolve against that
page's own locale instead, so a multi-locale API response always gets the right language
regardless of the server's current I18n.locale.
AI-generated alt text and title
Configure an OpenAI-compatible endpoint to have alt text and title generated automatically:
LatoCms.configure do |config|
config.llm_api_url = "https://api.openai.com/v1"
config.llm_model = "gpt-4o-mini"
config.llm_api_key = Rails.application.credentials.dig(:openai, :api_key)
# Optional, both default to true once the LLM is configured
config.llm_generate_alt_text = true
config.llm_generate_title = false
# Optional custom prompts (nil = built-in defaults, see LatoCms::Config::DEFAULT_LLM_PROMPTS)
config.llm_alt_text_prompt = "Describe this image for a screen reader. Site languages: {languages}. Shown on: {urls}."
config.llm_title_prompt = "Give this image a short title. Site languages: {languages}. Shown on: {urls}."
end
llm_api_url, llm_model and llm_api_key must all be set for the feature
to activate (it's off by default). Once configured, uploading a new image queues a background job that
sends it to the LLM once, asking for every enabled attribute
(llm_generate_alt_text, llm_generate_title, both on by default) in the same
request — the image itself is the bulk of the payload, so one call per attribute would upload it
twice — and writes back a translation for every locale in LatoCms.config.locales. It only runs once, right after upload — it never overwrites text
an admin later edits by hand. Generation is best effort: any failure (LLM unreachable, malformed
response, ...) is logged and simply leaves the text as it was. Switching an attribute off leaves it out of
both the post-upload run and the regenerate action.
Each attribute has its own prompt, overridable with llm_alt_text_prompt /
llm_title_prompt. Custom and default prompts can embed two placeholders, substituted on every
call:
-
{languages}— the configured locales, comma-separated (e.g.en, it). -
{urls}— the Frontend URLs of the pages currently using the media, comma-separated, so the model can look at where the image is actually shown. Pages without a frontend URL are skipped, and it readsnonewhen the media isn't used anywhere yet (always the case for the automatic post-upload run — the context only pays off when regenerating).
A prompt only needs to describe the task: the engine always appends its own response-format instruction (a JSON object keyed by attribute, then by locale), so a custom prompt can't break parsing. The task prompts of the enabled attributes travel together in that one request.
A single Regenerate with AI button in the media edit form (shown only for images, and only when
the LLM is configured) re-runs generation for every enabled attribute on demand, overwriting their current
text in every locale — again in one call. Unlike the automatic post-upload run, this one is triggered
by an admin waiting on it, so it runs as a Lato::Operation: the admin is taken to a live
progress screen rather than the request blocking (and potentially timing out) on the LLM call.
Image sizes
image and gallery fields can declare settings.sizes to generate
resized variants via image_processing. Each size produces an Active Storage variant that is
processed lazily on first request and exposed in the API under the attachment's sizes map.
Each size accepts width, height, and a resize mode:
-
limit(default): scale down to fit within the bounds, never upscale, keep aspect ratio. -
fit: scale to fit within the bounds, keep aspect ratio. -
fill: crop to exactlywidth×height.
# config/lato_cms/components/hero.yml
id: hero
name: Hero
fields:
cover:
name: Cover
type: image
settings:
sizes:
thumb:
width: 150
height: 150
resize: fill
medium:
width: 800
The API response includes the generated variant URLs alongside the original:
{
"media_id": 12,
"name": "cover.jpg",
"alt_text": "A dog running on the beach",
"title": "Dog on the beach",
"filename": "cover.jpg",
"url": "/rails/active_storage/blobs/.../cover.jpg",
"sizes": {
"thumb": "/rails/active_storage/representations/.../cover.jpg",
"medium": "/rails/active_storage/representations/.../cover.jpg"
}
}
Video poster
After a video is uploaded to the media library, a background job generates a poster image from it
via Active Storage previews (requires ffmpeg on the server) and attaches it to that media
item — once per media item, regardless of how many fields or pages reuse it. Poster generation is
best effort: when ffmpeg is unavailable or preview fails, the upload still succeeds and the
video is exposed without a poster.
# config/lato_cms/components/hero.yml
id: hero
name: Hero
fields:
trailer:
name: Trailer
type: video
settings:
accept: "video/mp4"
The API response exposes the video attachment with its poster URL (null until the job runs):
{
"media_id": 12,
"name": "trailer.mp4",
"alt_text": null,
"title": "Product trailer",
"filename": "trailer.mp4",
"content_type": "video/mp4",
"byte_size": 1048576,
"url": "/rails/active_storage/blobs/.../trailer.mp4",
"poster_url": "/rails/active_storage/blobs/.../trailer_poster.jpg"
}
Custom fields
Use type: custom when a component needs an editor that is not covered by built-in field types.
Set render to an application partial path. The partial receives field_id,
field_config, and page_field locals.
# config/lato_cms/components/hero.yml
id: hero
name: Hero
fields:
icon_note:
name: Icon note
type: custom
render: lato_cms/custom_fields/icon_note
required: false
settings:
placeholder: "Example: rocket|Hero launch section"
Create the partial in the host application:
<%# app/views/lato_cms/custom_fields/_icon_note.html.erb %>
<% settings = field_config["settings"] || {} %>
<% current_value = page_field&.value.to_s %>
<label class="form-label" for="fields_<%= field_id %>_value">
<%= field_config["name"] || field_id.humanize %>
</label>
<input type="text"
class="form-control"
name="fields[<%= field_id %>][value]"
id="fields_<%= field_id %>_value"
value="<%= current_value %>"
placeholder="<%= settings["placeholder"] %>">
Custom field values are saved like standard scalar fields when the input name is
fields[FIELD_ID][value]. Use settings for custom options consumed by the partial.
Using CMS content in the application
Use Lato CMS pages as structured content records for public or private pages. Field values are available in their parsed form, so numbers, booleans, dates, JSON values, multi-select values, and files can be consumed directly by application views or serializers.
page = LatoCms::Page.find_by(permalink: "/homepage")
title = page.fields.find_by(field_id: "title")&.parsed_value
image = page.fields.find_by(field_id: "image")&.media&.first
image_url = image&.url
Page translations
Each page belongs to a single locale. From a page, the Actions → Translations menu lets you link the corresponding pages in the other configured locales. Linked pages form a translation group with at most one page per locale, and the link is symmetric: linking A to B makes both aware of each other. On the page view, the locale badge becomes a dropdown to jump to the same page in another language.
When a page has linked translations, each component header shows a Clone dropdown. Picking a language copies that component's field values from the corresponding translation, replacing the current ones after confirmation; attachment fields keep referencing the same Media (not duplicated).
When an LLM is configured, the dropdown also offers
Clone & translate: same copy, but every string/textarea/text
field is additionally translated into the target page's locale with one batched LLM call
(HTML in text fields is preserved, only the visible text is translated).
Everything else — select/multiselect/boolean/number/date/color/json/custom values, and
media (including its alt text and title) — is left exactly as a plain clone would leave it:
media alt text and title are translated independently by their own per-locale generation, not
as part of cloning a page.
The clone itself is instant, as always. Only the translation step — the actual LLM call — runs as
a Lato::Operation (see the AI-generated alt text and title section above), landing the
admin on a live progress screen instead of blocking the request. If the LLM call fails, the operation
reports the failure, but the fields are still cloned (just untranslated) since that part already
committed synchronously before the translation step ever started.
Read a page's translations in the application or from the JSON API:
page = LatoCms::Page.find_by(permalink: "/homepage")
# Sibling pages in the other locales
page.translations # => ActiveRecord::Relation of pages
page.translation_for(:it) # => the linked Italian page, or nil
# In as_json / API output, translations are exposed as a locale => page map:
# { "it" => { id:, permalink:, frontend_url: }, ... }