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 and alt text are edited from the library's own edit form, not from the upload dialog — on upload, name defaults to the filename. Replacing the underlying file of an existing media item isn't supported: uploading a different file always creates a new media item, so that changing what a shared media item points to never silently changes it everywhere it's already in use. Deleting a media item still referenced by a field is blocked until it's removed from every field using it.

Alt text is translatable: it's only shown for image media, and the edit form has one tab per locale configured in LatoCms.config.locales. 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.alt_text without an explicit locale resolves against I18n.locale. When a media item is read off a page's field (page_field.attachments in the JSON API), its alt text resolves 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

Configure an OpenAI-compatible endpoint to have alt text 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)
end

All three must 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 and writes back an alt text translation for every locale in LatoCms.config.locales, in one call. 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 alt text as it was.

A Regenerate with AI button on the media edit form (shown only for images, and only when an LLM is configured) re-runs generation on demand, overwriting the current text in every locale. Unlike the automatic post-upload run, this one is triggered by an admin waiting on it, so it runs as a Lato::Operation instead: 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:

{
  "id": 12,
  "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):

{
  "id": 12,
  "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) — is left exactly as a plain clone would leave it: media alt text is translated independently by its 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 alt text 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