Internazionalizzazione (i18n)

In questa pagina

EmDash si integra con il routing i18n integrato di Astro per fornire gestione dei contenuti multilingue. Astro gestisce il routing degli URL e il rilevamento del locale; EmDash gestisce lo storage e il recupero dei contenuti tradotti.

Ogni traduzione è un’entrata di contenuto completa e indipendente con il proprio slug, stato e cronologia delle revisioni. La versione francese di un post può essere in bozza mentre la versione inglese è pubblicata.

Configurazione

Abilita i18n aggiungendo un blocco i18n alla tua configurazione Astro. EmDash legge la stessa configurazione per la lista dei locale, il locale predefinito e la catena di fallback.

import { defineConfig } from "astro/config";
import emdash, { local } from "emdash/astro";
import { sqlite } from "emdash/db";

export default defineConfig({
	i18n: {
		defaultLocale: "en",
		locales: ["en", "fr", "es"],
		fallback: { fr: "en", es: "en" },
	},
	integrations: [
		emdash({
			database: sqlite({ url: "file:./data.db" }),
			storage: local({
				directory: "./uploads",
				baseUrl: "/_emdash/api/media/file",
			}),
		}),
	],
});

Quando i18n non è presente nella configurazione Astro, tutte le funzionalità i18n sono disabilitate e EmDash si comporta come un CMS monolingue.

Come funzionano le traduzioni

EmDash usa un modello riga-per-locale. Ogni traduzione è una riga propria nel database con il proprio ID, slug e stato, collegata alle altre traduzioni tramite un identificatore condiviso translation_group. Una tabella posts con tre traduzioni appare così:

ec_posts:
id       | slug        | locale | translation_group | status
---------|-------------|--------|-------------------|----------
01ABC... | my-post     | en     | 01ABC...          | published
01DEF... | mon-article | fr     | 01ABC...          | draft
01GHI... | mi-entrada  | es     | 01ABC...          | published

Questo design significa:

  • Slug per locale/blog/my-post e /fr/blog/mon-article funzionano naturalmente
  • Pubblicazione per locale — pubblica la versione inglese mantenendo il francese in bozza
  • Revisioni per locale — ogni traduzione ha la propria cronologia delle revisioni
  • Query per singolo locale — le query di lista restituiscono voci per un solo locale

Interrogare contenuti tradotti

Voce singola

Passa locale a getEmDashEntry per recuperare una traduzione specifica. Quando omesso, il valore predefinito è il locale corrente della richiesta (impostato dal middleware i18n di Astro).

---
import { getEmDashEntry } from "emdash";

const { slug } = Astro.params;
const { entry: post, error } = await getEmDashEntry("posts", slug, {
  locale: Astro.currentLocale,
});

if (!post) return Astro.redirect("/404");
---

<article>
  <h1>{post.data.title}</h1>
</article>

Catena di fallback

Quando non esiste contenuto per il locale richiesto, EmDash segue la catena di fallback definita nella tua configurazione Astro. Dato fallback: { fr: "en" }:

  1. Prova il locale richiesto (fr)
  2. Prova il locale di fallback (en)
  3. Prova il locale predefinito

Il fallback si applica solo alle query di voce singola. Le query di lista restituiscono voci solo per il locale richiesto.

I menu sono per locale — lo stesso name (es. "primary") può esistere in diversi locale, tutti collegati tramite un translation_group condiviso. Gli elementi del menu risolvono i loro riferimenti di contenuto contro la versione del contenuto referenziato del locale attivo.

Il seguente componente recupera il menu principale per il locale attivo:

---
import { getMenu } from "emdash";

const menu = await getMenu("primary", { locale: Astro.currentLocale });
---

<nav aria-label="Principale">
  <ul>
    {menu?.items.map((item) => (
      <li><a href={item.url}>{item.label}</a></li>
    ))}
  </ul>
</nav>

Crea traduzioni di un menu esistente dalla lista Menu dell’admin — gli elementi vengono clonati con reference_id intatto (memorizza il translation_group del contenuto referenziato), quindi i link del nuovo menu puntano automaticamente al contenuto corretto per locale.

Tassonomie (categorie, tag)

I termini sono per locale. Le definizioni (_emdash_taxonomy_defs) sono anch’esse per locale, quindi label / labelSingular possono essere tradotti. Il pivot content_taxonomies.taxonomy_id memorizza il translation_group del termine, quindi una singola assegnazione copre tutti i locale del contenuto.

Il seguente esempio recupera categorie e i termini di un post per il locale attivo:

---
import { getTaxonomyTerms, getEntryTerms } from "emdash";

const categories = await getTaxonomyTerms("category", {
  locale: Astro.currentLocale,
});
const terms = await getEntryTerms("posts", post.id, undefined, {
  locale: Astro.currentLocale,
});
---

Tradurre un contenuto eredita automaticamente le assegnazioni dei termini dalla fonte — devi tradurre i termini stessi solo una volta, e ogni post che li usa si risolve al locale corretto in fase di lettura.

Lista della collezione

Filtra una collezione per locale:

---
import { getEmDashCollection } from "emdash";

const { entries: posts } = await getEmDashCollection("posts", {
  locale: Astro.currentLocale,
  status: "published",
});
---

<ul>
  {posts.map((post) => (
    <li><a href={`/${post.data.slug}`}>{post.data.title}</a></li>
  ))}
</ul>

Selettore di lingua

Usa getTranslations per costruire un selettore di lingua che collega alle traduzioni esistenti della voce corrente:

---
import { getTranslations } from "emdash";
import { getRelativeLocaleUrl } from "astro:i18n";

interface Props {
  collection: string;
  entryId: string;
}

const { collection, entryId } = Astro.props;
const { translations } = await getTranslations(collection, entryId);
---

<nav aria-label="Lingua">
  <ul>
    {translations.map((t) => (
      <li>
        <a
          href={getRelativeLocaleUrl(t.locale, `/blog/${t.slug}`)}
          aria-current={t.locale === Astro.currentLocale ? "page" : undefined}
        >
          {t.locale.toUpperCase()}
        </a>
      </li>
    ))}
  </ul>
</nav>

La funzione getTranslations restituisce tutte le varianti di locale nello stesso gruppo di traduzione:

const { translationGroup, translations } = await getTranslations("posts", post.entry.id);
// translations: [
//   { locale: "en", id: "01ABC...", slug: "my-post", status: "published" },
//   { locale: "fr", id: "01DEF...", slug: "mon-article", status: "draft" },
// ]

Gestire le traduzioni nell’admin

Lista dei contenuti

Quando i18n è abilitato, la lista dei contenuti mostra:

  • Una colonna locale che visualizza il locale di ogni voce
  • Un filtro locale nella barra degli strumenti per passare tra i locale

Creare traduzioni

Apri qualsiasi voce di contenuto nell’editor. La barra laterale mostra un pannello Traduzioni che elenca tutti i locale configurati. Per ogni locale:

  • “Traduci” appare per i locale senza traduzione — clicca per crearne una
  • “Modifica” appare per i locale con una traduzione esistente — clicca per navigare ad essa
  • Il locale corrente è contrassegnato con un segno di spunta

Quando si crea una traduzione, la nuova voce viene pre-compilata con i dati dal locale sorgente e le viene assegnato uno slug predefinito di {slug-sorgente}-{locale}. Modifica lo slug e il contenuto secondo necessità, poi salva.

Pubblicazione per locale

Ogni traduzione ha il proprio stato. Pubblica, annulla la pubblicazione o programma traduzioni indipendentemente. La versione francese può essere in bozza mentre la versione inglese è online.

API dei contenuti

Parametro locale

Tutte le route dell’API dei contenuti accettano un parametro di query locale opzionale:

GET /_emdash/api/content/posts?locale=fr
GET /_emdash/api/content/posts/my-post?locale=fr

Quando omesso, il valore predefinito è il locale predefinito configurato.

Creare traduzioni via API

Crea una traduzione passando locale e translationOf all’endpoint di creazione contenuto:

POST /_emdash/api/content/posts
Content-Type: application/json

{
  "locale": "fr",
  "translationOf": "01ABC...",
  "data": {
    "title": "Mon Article",
    "slug": "mon-article"
  }
}

La nuova voce condivide il translation_group della voce sorgente e inizia come bozza.

Elencare le traduzioni

Recupera tutte le traduzioni per una data voce:

GET /_emdash/api/content/posts/01ABC.../translations

Restituisce l’ID del gruppo di traduzione e un array di varianti locale con i loro ID, slug e stati.

CLI

La CLI supporta flag --locale nei comandi dei contenuti:

# Elencare i post in francese
emdash content list posts --locale fr

# Ottenere una voce specifica in francese
emdash content get posts my-post --locale fr

# Creare una traduzione francese di una voce esistente
emdash content create posts --locale fr --translation-of 01ABC...

Alimentare contenuti multilingue

I file seed esprimono le traduzioni usando locale e translationOf:

{
  "content": {
    "posts": [
      {
        "id": "welcome",
        "slug": "welcome",
        "locale": "en",
        "status": "published",
        "data": { "title": "Welcome" }
      },
      {
        "id": "welcome-fr",
        "slug": "bienvenue",
        "locale": "fr",
        "translationOf": "welcome",
        "status": "draft",
        "data": { "title": "Bienvenue" }
      }
    ]
  }
}

La voce del locale sorgente deve apparire prima delle sue traduzioni nel file seed affinché i riferimenti translationOf si risolvano correttamente.

Traducibilità dei campi

Ogni campo ha un’impostazione translatable (predefinito: true). Quando si crea una traduzione:

  • I campi traducibili vengono pre-compilati dal locale sorgente per la modifica
  • I campi non traducibili vengono copiati e mantenuti sincronizzati in tutte le traduzioni del gruppo

I campi di sistema come status, published_at e author_id sono sempre per locale e non vengono mai sincronizzati.

Strategia URL

EmDash non gestisce gli URL dei locale — Astro gestisce il routing. Pattern comuni:

# prefix-other-locales (predefinito Astro)
/blog/my-post          → en (locale predefinito, nessun prefisso)
/fr/blog/mon-article   → fr

# prefix-always
/en/blog/my-post       → en
/fr/blog/mon-article   → fr

Usa getRelativeLocaleUrl da astro:i18n per costruire URL corretti indipendentemente dalla modalità di routing.

Sitemap

La sitemap per collezione a /sitemap-{collection}.xml è consapevole del locale. Quando i18n è abilitato, ogni traduzione viene emessa come voce <url> propria, con il prefisso locale risolto tramite getRelativeLocaleUrl di Astro. La tua impostazione prefixDefaultLocale e qualsiasi mapping personalizzato di path locale vengono rispettati automaticamente.

Le traduzioni sorelle sono collegate con alternate xhtml:link in modo che i motori di ricerca possano servire la lingua corretta a ogni utente:

<url>
  <loc>https://example.com/blog/hello</loc>
  <lastmod>2026-05-28T16:33:15.461Z</lastmod>
  <xhtml:link rel="alternate" hreflang="en" href="https://example.com/blog/hello" />
  <xhtml:link rel="alternate" hreflang="fr" href="https://example.com/fr/blog/bonjour" />
  <xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/blog/hello" />
</url>

Le sorelle vengono raggruppate per translation_group, quindi una riga aggiunta in seguito (una nuova variante locale di un post esistente) appare automaticamente come alternate su ogni altra variante. I siti con un singolo locale producono una sitemap semplice senza namespace xhtml.

Alternate hreflang nell’head della pagina

Gli stessi alternate appartengono all’<head> di ogni pagina di contenuto. Se il tuo layout usa <EmDashHead>, questo è automatico: quando i18n è abilitato e il contesto della pagina include content, emette un <link rel="alternate"> per traduzione sorella pubblicata — includendo un link auto-referenziale, come raccomandato da Google — più x-default:

<link rel="alternate" hreflang="en" href="https://example.com/blog/hello" />
<link rel="alternate" hreflang="fr" href="https://example.com/fr/blog/bonjour" />
<link rel="alternate" hreflang="x-default" href="https://example.com/blog/hello" />

Per head fatti a mano, risolvi gli alternate con getHreflangAlternates:

---
import { getEmDashEntry, getHreflangAlternates } from "emdash";

const { entry } = await getEmDashEntry("posts", Astro.params.slug);
const alternates = await getHreflangAlternates("posts", entry.data.id, {
	siteUrl: Astro.url.origin,
});
---

<head>
	{alternates.map((a) => <link rel="alternate" hreflang={a.hreflang} href={a.href} />)}
</head>

Il comportamento corrisponde esattamente alla sitemap:

  • x-default punta alla variante del locale predefinito. Quando il locale predefinito non ha una traduzione pubblicata, ricade sulla prima variante instradabile, quindi l’insieme non manca mai di un x-default.
  • Le sorelle non pubblicate sono escluse — le traduzioni in bozza non filtrano mai negli alternate.
  • I locale non instradabili vengono eliminati. Una riga il cui locale non è nei tuoi i18n.locales configurati non può essere servita, e collegare i motori di ricerca a un 404 è peggio che nessun link.
  • Le voci non tradotte ottengono comunque un alternate auto-referenziale e x-default quando i18n è abilitato, in specchio alla sitemap.
  • Con i18n disabilitato, il risultato è vuoto e nessuna query viene eseguita.

Gli URL sono costruiti dal urlPattern della collezione e localizzati tramite la tua configurazione di routing i18n di Astro (prefixDefaultLocale, mapping personalizzati di path locale), quindi head e sitemap concordano sempre.

Importare contenuti multilingue

Importa contenuti WordPress tramite lo strumento di migrazione admin — vedi Importazione contenuti e Migrare da WordPress. Un export WXR non porta la struttura di locale e gruppo di traduzione che WPML o Polylang aggiungono, quindi i contenuti importati arrivano nel tuo locale predefinito.

Per costruire traduzioni dal contenuto importato, crea la voce tradotta e collegala all’originale:

emdash content create posts --locale fr --translation-of 01ABC...

Questo è lo stesso workflow --locale / --translation-of mostrato sopra in Alimentare contenuti multilingue, applicato dopo il completamento dell’importazione.

Prossimi passi