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 reads none when 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 exactly width×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: }, ... }
You are offline You are online