Split-by-Directory Translation

For pages with large body content — blog posts, documentation articles, landing pages — translating every paragraph and heading individually via data-rosey keys can be impractical. Split-by-directory is an alternative approach where body content is translated via per-locale content collections managed natively by your SSG, while shared UI strings (navigation, footer, breadcrumbs, sidebar headings) continue using Rosey.

The two systems work together: the SSG builds locale pages from native content files, and Rosey runs in postbuild to translate the remaining data-rosey elements. The connector works identically on these hybrid pages — no changes to the RCC setup are needed.

#When to use it

Use split-by-directory when:

Use Rosey alone (without split-by-directory) when:

You can mix both approaches on the same site — some pages pure Rosey, others split-by-directory for body content with Rosey for UI strings.

How it works

The SSG builds locale pages natively

Each locale gets its own content directory. For a blog, this might look like:

src/content/
  blog/           # English (default locale)
    my-post.md
    another-post.md
  blog_fr/         # French translations
    my-post.md
    another-post.md
  blog_de/         # German translations
    my-post.md
    another-post.md

Each locale file is a full content file with translated frontmatter and body. Editors manage these like any normal CloudCannon collection — they can edit the French version of a blog post directly in the Content Editor or Visual Editor.

The SSG builds these into locale-prefixed URLs:

Rosey merges with pre-existing locale pages

When Rosey runs in the postbuild, it encounters pages that already exist at locale URLs (the ones the SSG built natively). Rosey respects existing content — it doesn't overwrite the page. It only translates elements that have data-rosey attributes, such as shared navigation links, footer text, or breadcrumb labels that appear in the page layout.

This means:

The RCC works the same way

The connector doesn't know or care whether a page was built natively or by Rosey. It finds data-rosey elements, creates inline editors, and connects them to locale data files. On a split-by-directory blog post, the data-rosey elements are the shared UI strings — breadcrumbs, sidebar headings, "Share this article" labels — and those get the locale switcher and inline editing as normal. The body content (which has no data-rosey) is untouched by the connector.

Setup (Astro example)

The specifics vary by SSG, but the pattern is the same. This example uses Astro with content collections.

1. Create per-locale content directories

Create a content directory for each locale, mirroring the structure of the default-language collection:

src/content/
  blog/
    my-post.md
  blog_fr/
    my-post.md
  blog_de/
    my-post.md

Each locale file has its own translated frontmatter and body content.

2. Add a locale config utility

Create a central mapping of locale codes to collection names and metadata:

// src/lib/locales.ts
export const locales = {
  fr: { collection: "blog_fr", label: "Français", dateLocale: "fr-FR" },
  de: { collection: "blog_de", label: "Deutsch", dateLocale: "de-DE" },
} as const;

export const localeCodes = Object.keys(locales);

3. Create locale routes

Use a dynamic [locale] parameter to generate pages for each locale from the locale content collections:

---
// src/pages/[locale]/blog/[...slug].astro
import { getCollection } from "astro:content";
import { locales, localeCodes } from "@/lib/locales";
import BlogPostLayout from "@/layouts/BlogPostLayout.astro";

export async function getStaticPaths() {
  const paths = [];
  for (const locale of localeCodes) {
    const { collection } = locales[locale];
    const posts = await getCollection(collection);
    for (const post of posts) {
      paths.push({
        params: { locale, slug: post.slug },
        props: { post, locale },
      });
    }
  }
  return paths;
}

const { post, locale } = Astro.props;
const { Content } = await post.render();
---
<BlogPostLayout
  title={post.data.title}
  locale={locale}
  roseyRoot={`blog/${post.slug}`}
>
  <Content />
</BlogPostLayout>

4. Align data-rosey-root across locales

Locale pages must resolve to the same Rosey keys as the English page so that shared UI strings (breadcrumbs, sidebar headings) share translations. Set data-rosey-root to the English-equivalent path on all versions of the page:

---
// BlogPostLayout.astro
const { title, locale, roseyRoot = "" } = Astro.props;
---
<main data-rosey-root={roseyRoot}>
  <nav aria-label="Breadcrumb" data-rosey-ns="breadcrumb">
    <a href="/" data-rosey="home">Home</a>
    <a href="/blog/" data-rosey="blog">Blog</a>
    <!-- key: blog/my-post:breadcrumb:home — same on /blog/my-post/ and /fr/blog/my-post/ -->
  </nav>

  <h1>{title}</h1>
  <!-- No data-rosey on the title — it's translated natively from the locale content file -->

  <slot />
  <!-- Body content from the locale's markdown — no data-rosey needed -->

  <aside data-rosey-ns="sidebar">
    <h2 data-rosey="latest-posts">Latest Posts</h2>
    <!-- key: blog/my-post:sidebar:latest-posts — translated by Rosey -->
  </aside>
</main>

The roseyRoot prop is set to blog/my-post (the English path) on all locale versions. This ensures data-rosey keys like blog/my-post:breadcrumb:home are identical on /blog/my-post/, /fr/blog/my-post/, and /de/blog/my-post/.

5. Suppress data-rosey on frontmatter-driven fields

Fields that come from the locale content file (title, description, tags) are already natively translated — they don't need Rosey. If your template components auto-derive data-rosey from data-prop, opt out with data-rosey={false}:

<h1 data-rosey={false}>{title}</h1>
<p data-rosey={false}>{description}</p>

If your template doesn't auto-derive data-rosey, simply don't add the attribute to these elements.

6. Configure CloudCannon collections

Add collections_config entries for each locale collection so editors can manage them in CloudCannon's sidebar:

# cloudcannon.config.yml
collections_config:
  blog:
    path: src/content/blog
    url: /blog/[slug]/
    _enabled_editors:
      - content
      - visual
  blog_fr:
    path: src/content/blog_fr
    name: Blog (French)
    icon: translate
    url: /fr/blog/[slug]/
    _enabled_editors:
      - content
      - visual
  blog_de:
    path: src/content/blog_de
    name: Blog (German)
    icon: translate
    url: /de/blog/[slug]/
    _enabled_editors:
      - content
      - visual

Editors see "Blog (French)" and "Blog (German)" as separate collections in the sidebar and can edit full translated documents directly.

What the editor experience looks like

On a split-by-directory blog post:

Postbuild pipeline

The postbuild script doesn't change. The same pipeline works for both pure-Rosey pages and split-by-directory pages:

#!/usr/bin/env bash
npx rosey generate --source dist
npx rosey-cloudcannon-connector write-locales --source rosey --dest dist
mv ./dist ./_untranslated_site
npx rosey build --source _untranslated_site --dest dist --default-language en --default-language-at-root --exclusions "\.(html?)$"

Rosey's generate step scans all built pages (including the locale pages the SSG produced) for data-rosey elements. The build step then translates those elements on every page, whether the page was built by the SSG or by Rosey. Pages that already exist at a locale URL are updated in place — Rosey doesn't duplicate them.