EmDash si integra con il routing i18n integrato di Astro per fornire la gestione dei contenuti multilingue. Astro gestisce il routing degli URL e il rilevamento del locale; EmDash gestisce l’archiviazione e il recupero dei contenuti tradotti.
Ogni traduzione è una voce 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.
Configurare i locale
Abilita l’i18n aggiungendo un blocco i18n alla configurazione Astro. EmDash legge la stessa configurazione per l’elenco 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 monolingua.
Come funzionano le traduzioni
EmDash usa un modello riga per locale. Ogni traduzione è una propria riga nel database con il proprio ID, slug e stato, collegata ad 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 implica:
- Slug per locale —
/blog/my-poste/fr/blog/mon-articlefunzionano in modo naturale - Pubblicazione per locale — pubblica la versione inglese mantenendo quella francese in bozza
- Revisioni per locale — ogni traduzione ha la propria cronologia delle revisioni
- Query mono-locale — le query di elenco restituiscono voci per un solo locale
Slug, ID delle voci e ID del database
Una voce ha due identificatori con scopi diversi:
entry.idè lo slug della voce. Usalo per costruire l’URL pubblico.entry.data.idè l’ID del database. Usalo per le operazioni API e gli helper che fanno riferimento a una riga di contenuto archiviata, inclusigetTranslations()egetEntryTerms().
Le traduzioni hanno ID del database diversi perché ogni locale è una riga separata. Il translation_group condiviso indica che le righe sono traduzioni dello stesso contenuto. EmDash gestisce quel gruppo quando crei una traduzione; i template di solito necessitano solo dell’ID del database di una qualsiasi riga del gruppo.
Interrogare il contenuto tradotto
Voce singola
Passa Astro.currentLocale a getEmDashEntry su una route multilingue. Astro conosce il locale selezionato dal router, mentre EmDash ha bisogno del valore esplicito per disambiguare gli slug che possono esistere in più di un locale. Fai lo stesso per le query sulle collezioni.
---
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 nel locale richiesto non esiste una voce pubblicata corrispondente, getEmDashEntry segue la catena di fallback definita nella configurazione Astro. In modalità anteprima o modifica visuale, la stessa ricerca può restituire una bozza. Con fallback: { fr: "en" }:
- Prova il locale richiesto (
fr) - Prova il locale di fallback (
en) - Prova il locale predefinito se non è già nella catena
Il fallback si applica solo alle query di voce singola. Le query di elenco restituiscono voci solo per il locale richiesto.
Ogni ricerca di fallback usa lo stesso argomento id. Ad esempio, una richiesta per lo slug about può ricadere dal francese su una voce inglese il cui slug è anch’esso about. Una richiesta per a-propos non può trovare una voce inglese il cui slug è about: le due righe usano identificatori pubblici diversi. Usa getTranslations() per trovare e collegare varianti di locale con slug diversi.
Menu
I menu sono per locale — lo stesso name (ad es. "primary") può esistere in diversi
locale, tutti collegati tramite un translation_group condiviso. Gli elementi del menu risolvono i
riferimenti al contenuto rispetto alla versione nel locale attivo del contenuto referenziato.
Il componente seguente 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 dall’elenco Menus 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. Ogni locale può avere la propria definizione, quindi label e
labelSingular possono essere tradotti, mentre hierarchical e collections
sono condivisi da ogni locale (vedi
Tradurre tassonomie e termini). Il pivot
content_taxonomies.taxonomy_id memorizza il translation_group del termine, quindi una
singola assegnazione copre ogni locale del contenuto.
L’esempio seguente recupera le 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.data.id, undefined, {
locale: Astro.currentLocale,
});
---
Tradurre un contenuto eredita automaticamente le assegnazioni dei termini della fonte — devi tradurre i termini stessi solo una volta, e ogni post che li usa si risolve al locale corretto al momento della lettura.
Riparare le discrepanze di locale delle tassonomie
Quando l’admin carica il manifest del sito, EmDash segnala nei log del server quando
le definizioni delle tassonomie o i termini usano un locale che non è tra i
i18n.locales configurati del sito. Senza una configurazione i18n, en è il locale effettivo.
Queste righe restano invariate perché EmDash non può dedurre quale locale configurato
fosse previsto per il contenuto esistente.
Esegui il backup del database, poi ispeziona le righe interessate indicate nell’avviso:
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, aggiornalo 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 capitalizzazione di i18n.locales. Prima di aggiornare, verifica se esiste una riga con
lo stesso nome di tassonomia e locale di destinazione, oppure lo stesso nome di termine, slug e locale di destinazione.
Queste combinazioni sono univoche; se esiste già una riga di destinazione, riconcilia le traduzioni invece di applicare un aggiornamento massivo del locale. Riavvia EmDash e
conferma che l’avviso non compaia più.
Elenco 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.id}`}>{post.data.title}</a></li>
))}
</ul>
Costruire un 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);
const publishedTranslations = translations.filter(
(translation): translation is typeof translation & { slug: string } =>
translation.status === "published" && translation.slug !== null
);
---
<nav aria-label="Language">
<ul>
{publishedTranslations.map((translation) => (
<li>
<a
href={getRelativeLocaleUrl(translation.locale, `/blog/${translation.slug}`)}
aria-current={translation.locale === Astro.currentLocale ? "page" : undefined}
>
{translation.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.data.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
Elenco dei contenuti
Quando l’i18n è abilitato, l’elenco dei contenuti mostra:
- Una colonna locale con il locale di ogni voce
- Un filtro locale nella barra degli strumenti per passare da un locale all’altro
Creare traduzioni
Apri una voce di contenuto nell’editor. La barra laterale mostra un pannello Translations con tutti i locale configurati. Per ogni locale:
- “Translate” per i locale senza traduzione — clicca per crearne una
- “Edit” per i locale con una traduzione esistente — clicca per aprirla
- Il locale corrente è contrassegnato con un segno di spunta
Quando crei una traduzione, la nuova voce è precompilata con i dati del locale di origine e riceve uno slug predefinito {source-slug}-{locale}. Modifica slug e contenuto secondo necessità, poi salva.
Pubblicazione per locale
Ogni traduzione ha il proprio stato. Pubblica, depubblica o pianifica le traduzioni in modo indipendente. La versione francese può essere in bozza mentre quella inglese è online.
Usare l’API dei contenuti
Parametro locale
Le route dell’API dei contenuti richiedono una sessione autenticata o un token bearer. Le route di elenco accettano un parametro di query locale opzionale. Una route di voce singola lo accetta anche quando il percorso usa uno slug; gli ID del database sono univoci a livello globale e non richiedono disambiguazione del locale.
GET /_emdash/api/content/posts?locale=fr
GET /_emdash/api/content/posts/my-post?locale=fr
Se una richiesta di elenco omette locale, viene usato il locale predefinito configurato.
Creare traduzioni via API
Crea una traduzione passando locale e translationOf all’endpoint di creazione del contenuto:
POST /_emdash/api/content/posts
Content-Type: application/json
X-EmDash-Request: 1
{
"locale": "fr",
"translationOf": "01ABC...",
"slug": "mon-article",
"data": {
"title": "Mon Article"
}
}
translationOf è l’ID del database della riga di origine, ad esempio entry.data.id. La nuova voce condivide il translation_group della voce di origine 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 di locale con i rispettivi ID, slug e stati.
Usare la CLI
Dopo aver autenticato la CLI, usa i flag --locale sui 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 come bozza
emdash content create posts \
--locale fr \
--translation-of 01ABC... \
--slug mon-article \
--data '{"title":"Mon article"}' \
--draft
content create richiede input da --data, --file o --stdin. Pubblica dopo la creazione a meno che non si passi --draft.
Popolare contenuti multilingue (seed)
I file seed esprimono le traduzioni con 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 di origine deve comparire prima delle sue traduzioni nel file seed, così i riferimenti translationOf si risolvono correttamente.
Scegliere quali campi sono traducibili
Ogni campo ha un’impostazione translatable (predefinito: true). Quando crei una traduzione:
- I campi traducibili sono precompilati dal locale di origine per la modifica
- I campi non traducibili vengono copiati e mantenuti sincronizzati in tutte le traduzioni del gruppo
Su una collezione con revisioni, pubblicare una voce copia nelle altre traduzioni i valori non traducibili che ha modificato; salvare una bozza modifica solo quella voce. Se un’altra traduzione ha una bozza in sospeso che ha cambiato uno di quei valori, la bozza mantiene il proprio valore e pubblicare quella traduzione lo copia nel resto del gruppo.
I campi di sistema come status, published_at e author_id sono sempre per locale e non vengono mai sincronizzati.
Un campo reference è condiviso anziché sincronizzato: EmDash associa i collegamenti al gruppo di traduzione, quindi ogni traduzione di una voce ha una sola selezione e modificarla da qualsiasi locale la cambia per tutte.
Costruire URL di locale
EmDash memorizza il locale; Astro gestisce il routing pubblico. La configurazione EmDash supportata lascia il locale predefinito senza prefisso:
# prefix-other-locales (predefinito Astro)
/blog/my-post → en (locale predefinito, nessun prefisso)
/fr/blog/mon-article → fr
Usa getRelativeLocaleUrl da astro:i18n per aggiungere il prefisso corretto e qualsiasi mappatura personalizzata del percorso locale. Non abilitare un prefisso per il locale predefinito; come descritto in Configurare i locale, quella strategia di routing impedisce il caricamento delle pagine admin iniettate.
Sitemap
La sitemap per collezione su /sitemap-{collection}.xml è consapevole del locale. Include voci pubblicate da collezioni instradabili con SEO abilitato. Sono escluse le voci eliminate, quelle senza slug e quelle contrassegnate noindex. Ogni traduzione inclusa diventa una propria voce <url>. EmDash costruisce il percorso dal urlPattern della collezione, poi applica il prefisso locale di Astro e qualsiasi mappatura personalizzata del path locale.
I fratelli di traduzione sono collegati tra loro con alternate 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 variante di locale pubblicata compare come alternate su ogni altra variante pubblicata e indicizzabile. I locale assenti da i18n.locales sono omessi perché Astro non ha una route per essi. I siti con un solo locale producono una sitemap semplice senza namespace xhtml.
Aggiungere link hreflang all’intestazione della pagina
Gli stessi alternate vanno nel <head> di ogni pagina di contenuto. Se il layout usa <EmDashHead>, avviene in automatico: quando l’i18n è abilitato e il contesto della pagina include content, viene emesso un <link rel="alternate"> per ogni fratello di traduzione pubblicato — incluso un link autoreferenziale, 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> gestiti manualmente, risolvi gli alternate con getHreflangAlternates:
---
import { getEmDashEntry, getHreflangAlternates } from "emdash";
const { entry, error } = await getEmDashEntry("posts", Astro.params.slug, {
locale: Astro.currentLocale,
});
if (error) return new Response("Server error", { status: 500 });
if (!entry) return Astro.redirect("/404");
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 alla sitemap:
x-defaultpunta alla variante del locale predefinito. Quando il locale predefinito non ha una traduzione pubblicata, ricade sulla prima variante instradabile, così l’insieme non resta mai senzax-default.- I fratelli non pubblicati sono esclusi — le traduzioni in bozza non compaiono mai negli alternate.
- I fratelli
noindexsono esclusi. Se la voce corrente ènoindex, non viene restituito alcun alternate. - I locale non instradabili vengono scartati. Una riga il cui locale non è tra i
i18n.localesconfigurati non può essere servita, e collegare i motori di ricerca a un 404 è peggio che non collegarli. - Le voci non tradotte ottengono comunque un alternate autoreferenziale e
x-defaultquando l’i18n è abilitato, in linea con la sitemap. - Con l’i18n disabilitato, il risultato è vuoto e non vengono eseguite query.
Gli URL sono costruiti dal urlPattern della collezione e localizzati tramite la configurazione i18n di Astro. getHreflangAlternates() richiede un URL assoluto del sito. Usa siteUrl dalla chiamata o l’URL nelle impostazioni del sito; senza l’uno o l’altro, restituisce un array vuoto perché i link hreflang devono essere assoluti.
Importare contenuti multilingue
Importa contenuti WordPress tramite lo strumento di migrazione dell’admin — vedi Importazione contenuti e Migrare da WordPress. Un’esportazione WXR non include la struttura di locale e gruppo di traduzione aggiunta da WPML o Polylang, quindi i contenuti importati finiscono nel locale predefinito.
Per costruire traduzioni a partire dai contenuti importati, crea la voce tradotta come bozza e collegala all’ID del database originale:
emdash content create posts \
--locale fr \
--translation-of 01ABC... \
--slug mon-article \
--data '{"title":"Mon article"}' \
--draft
È la stessa relazione --locale e --translation-of usata dai file seed, applicata al termine 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