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-poste/fr/blog/mon-articlefunzionano 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" }:
- Prova il locale richiesto (
fr) - Prova il locale di fallback (
en) - 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.
Menu
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-defaultpunta 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 unx-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.localesconfigurati 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-defaultquando 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
- Interrogare i contenuti — Riferimento completo dell’API di query
- Lavorare con i contenuti — Gestione dei contenuti nell’admin
- Routing i18n di Astro — Configurazione del routing di Astro