Internazionalizzazione (i18n)

In questa pagina

EmDash si integra con il routing i18n integrato di Astro per fornire la gestione di 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’entry 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 sua lista di locale, locale predefinito e 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 è la propria riga nel database con il proprio ID, slug e stato, collegata alle altre traduzioni tramite un identificatore translation_group condiviso. 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 mono-locale — le query di lista restituiscono entry per un solo locale

Interrogare contenuti tradotti

Entry singola

Passa locale a getEmDashEntry per recuperare una traduzione specifica. Quando omesso, usa per default 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 entry singola. Le query di lista restituiscono entry 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 locale attivo del contenuto referenziato.

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="Primary">
  <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 ogni locale del contenuto.

Il seguente esempio recupera categorie e 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 di termini della fonte — devi tradurre i termini stessi solo una volta, e ogni post che li usa risolve al locale giusto al momento della lettura.

Riparare discrepanze di locale nelle tassonomie

Quando l’admin carica il manifesto del sito, EmDash avverte nei log del server quando definizioni di tassonomia o termini usano un locale che non è nei i18n.locales configurati del sito. Senza una configurazione i18n, en è il locale effettivo. Queste righe vengono lasciate invariate perché EmDash non può inferire quale locale configurato il contenuto esistente doveva usare.

Esegui il backup del database, poi ispeziona le righe interessate nominate nell’avvertimento:

SELECT id, name, locale FROM _emdash_taxonomy_defs ORDER BY name, locale;
SELECT id, name, slug, locale FROM taxonomies ORDER BY name, slug, locale;

Dopo aver confermato il locale previsto per ogni riga, aggiornala per id:

UPDATE _emdash_taxonomy_defs SET locale = 'ja' WHERE id = '<definition-id>';
UPDATE taxonomies SET locale = 'ja' WHERE id = '<term-id>';

Usa la stessa grafia di i18n.locales. Prima di aggiornare, verifica se esiste una riga con lo stesso nome di tassonomia e locale target, o lo stesso nome di termine, slug e locale target. Quelle combinazioni sono uniche; se una riga target esiste già, riconcilia le traduzioni invece di applicare un aggiornamento di locale in massa. Riavvia EmDash e conferma che l’avvertimento non appare più.

Lista di collezioni

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 dell’entry 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 contenuti

Quando i18n è abilitato, la lista contenuti mostra:

  • Una colonna locale che mostra il locale di ogni entry
  • Un filtro locale nella toolbar per passare tra i locale

Creare traduzioni

Apri qualsiasi entry di contenuto nell’editor. La sidebar 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 traduzione esistente — clicca per navigare ad essa
  • Il locale corrente è contrassegnato con un segno di spunta

Quando si crea una traduzione, la nuova entry è pre-compilata con dati dal locale fonte e assegnata uno slug predefinito di {source-slug}-{locale}. Modifica slug e contenuto secondo necessità, poi salva.

Pubblicazione per locale

Ogni traduzione ha il proprio stato. Pubblica, depubblica o pianifica traduzioni indipendentemente. La versione francese può essere in bozza mentre la versione inglese è live.

Content API

Parametro locale

Tutte le route dell’API di contenuto 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, usa 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 entry condivide il translation_group dell’entry fonte e inizia come bozza.

Elencare traduzioni

Recupera tutte le traduzioni per una data entry:

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 sui comandi di contenuto:

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

# Ottenere un'entry specifica in francese
emdash content get posts my-post --locale fr

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

Alimentare contenuti multilingue

I file seed esprimono 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" }
      }
    ]
  }
}

L’entry del locale fonte 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 (default: true). Quando si crea una traduzione:

  • I campi traducibili sono pre-compilati dal locale fonte per la modifica
  • I campi non traducibili sono copiati e mantenuti sincronizzati tra tutte le traduzioni del gruppo

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

Strategia URL

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

# prefix-other-locales (default 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 è emessa come propria entry <url>, con il prefisso locale risolto tramite getRelativeLocaleUrl di Astro. La tua impostazione prefixDefaultLocale e qualsiasi mapping personalizzato di path locale sono rispettati automaticamente.

I fratelli di traduzione sono collegati trasversalmente con alternative xhtml:link affinché 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>

I fratelli sono raggruppati per translation_group, quindi una riga aggiunta successivamente (una nuova variante locale di un post esistente) appare automaticamente come alternativa su ogni altra variante. I siti con un singolo locale producono una sitemap semplice senza namespace xhtml.

Alternative hreflang nell’head della pagina

Le stesse alternative 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 fratello di traduzione pubblicato — incluso un link auto-referenziante, come raccomanda 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 scritti a mano, risolvi le alternative 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.
  • I fratelli non pubblicati sono esclusi — le traduzioni in bozza non finiscono mai nelle alternative.
  • 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 entry non tradotte ottengono comunque un’alternativa auto-referenziante e x-default quando i18n è abilitato, rispecchiando la 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 Astro (prefixDefaultLocale, mapping personalizzati di path locale), quindi head e sitemap concordano sempre.

Importare contenuti multilingue

Importa contenuti WordPress tramite lo strumento di migrazione dell’admin — vedi Import contenuti e Migrare da WordPress. Un export WXR non porta la struttura di locale e gruppo di traduzione che WPML o Polylang aggiungono, quindi il contenuto importato finisce nel tuo locale predefinito.

Per costruire traduzioni dal contenuto importato, crea l’entry tradotta e collegala all’originale:

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

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

Prossimi passi