EmDash si integra con il routing i18n integrato di Astro per offrire la gestione di 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 di Astro. EmDash legge questa 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 di Astro, tutte le funzionalità i18n sono disabilitate ed EmDash si comporta come un CMS monolingue.
Come funzionano le traduzioni
EmDash usa un modello una riga per locale. Ogni traduzione è una riga a sé nel database, con il proprio ID, slug e stato, collegata alle altre traduzioni tramite un identificatore translation_group condiviso. Una tabella di post con tre traduzioni si presenta 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 che:
- Slug per locale —
/blog/my-poste/fr/blog/mon-articlefunzionano in modo naturale - Pubblicazione per locale — pubblica la versione inglese mantenendo il francese in bozza
- Revisioni per locale — ogni traduzione ha la propria cronologia delle revisioni
- Query su un singolo locale — le query di elenco restituiscono le voci di un solo locale
Slug, ID delle voci e ID del database
Una voce ha due identificatori con scopi diversi:
entry.idè lo slug, preceduto dal locale ogni volta che gli URL del locale hanno un prefisso (en/my-postper un locale non predefinito,my-postper il locale predefinito a meno che non sia impostatoprefixDefaultLocale). Usalo così com’è quando le voci vengono instradate alla radice del sito, ad esempio/${entry.id}.entry.data.slugè lo slug senza locale. Usalo quando la route aggiunge già il segmento del locale, e quando passi uno slug agetEmDashEntry().entry.data.idè l’ID del database. Usalo per le operazioni API e per gli helper che si riferiscono a una riga di contenuto archiviata, tra cuigetTranslations()egetEntryTerms().
Le traduzioni hanno ID del database diversi perché ogni locale è una riga separata. Il loro translation_group condiviso registra che le righe sono traduzioni dello stesso contenuto. EmDash gestisce quel gruppo quando crei una traduzione; i template normalmente hanno bisogno 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 suo 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 di collezione.
---
import { getEmDashEntry } from "emdash";
const { slug } = Astro.params;
const { entry: post, error } = await getEmDashEntry("posts", slug, {
locale: Astro.currentLocale,
});
if (!post) return Astro.rewrite("/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 della configurazione di Astro. In modalità anteprima o di modifica visuale, la stessa ricerca può restituire una bozza. Dato fallback: { fr: "en" }:
- Provare il locale richiesto (
fr) - Provare il locale di fallback (
en) - Provare il locale predefinito se non è già nella catena
Il fallback si applica solo alle query su una singola voce. Le query di elenco restituiscono solo le voci del locale richiesto.
Ogni ricerca di fallback usa lo stesso argomento id. Ad esempio, una richiesta per lo slug about può ripiegare dal francese su una voce inglese il cui slug è anch’esso about. Una richiesta per a-propos non può scoprire 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 esempio "primary") può esistere in più locale, tutti collegati tramite un translation_group condiviso. Le voci di menu risolvono i loro riferimenti ai contenuti rispetto alla versione del contenuto referenziato nel locale attivo.
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 le traduzioni di un menu esistente dall’elenco Menus dell’admin: le voci vengono clonate con reference_id intatto (memorizza il translation_group del contenuto referenziato), quindi i link del nuovo menu puntano automaticamente al contenuto corretto per ciascun locale.
Tassonomie (categorie, tag)
I termini sono per locale. Ogni locale può avere una propria definizione, quindi anche label e labelSingular possono essere tradotti, mentre hierarchical e collections sono condivisi da tutti i locale (vedi Tradurre tassonomie e termini). Il pivot content_taxonomies.taxonomy_id memorizza il translation_group del termine, quindi una singola assegnazione copre tutti i 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 di termini della sorgente: devi tradurre i termini stessi una sola volta, e ogni post che li usa viene risolto nel locale corretto al momento della lettura.
Riparare le discrepanze di locale delle tassonomie
Quando l’admin carica il manifest del sito, EmDash emette un avviso nei log del server se le definizioni di tassonomia o i termini usano un locale che non è presente negli i18n.locales configurati del sito. Senza una configurazione i18n, en è il locale effettivo. Queste righe vengono lasciate invariate perché EmDash non può dedurre quale locale configurato fosse inteso per il contenuto esistente.
Esegui un backup del database, quindi 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, aggiornala per id:
UPDATE _emdash_taxonomy_defs SET locale = 'ja' WHERE id = '<definition-id>';
UPDATE taxonomies SET locale = 'ja' WHERE id = '<term-id>';
Usa esattamente le maiuscole e minuscole di i18n.locales. Prima di aggiornare, verifica se esiste una riga con lo stesso nome di tassonomia e lo stesso locale di destinazione, oppure con lo stesso nome di termine, slug e locale di destinazione. Queste combinazioni sono univoche; se la riga di destinazione esiste già, riconcilia le traduzioni invece di applicare un aggiornamento di locale in blocco. 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 rimandi 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 dello 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 è abilitata, l’elenco dei contenuti mostra:
- Una colonna del locale che mostra il locale di ogni voce
- Un filtro per locale nella barra degli strumenti per passare da un locale all’altro
Creare traduzioni
Apri una qualsiasi voce di contenuto nell’editor. La barra laterale mostra un pannello Translations che elenca tutti i locale configurati. Per ogni locale:
- «Translate» compare per i locale senza traduzione: fai clic per crearne una
- «Edit» compare per i locale con una traduzione esistente: fai clic per aprirla
- Il locale corrente è contrassegnato da un segno di spunta
Selezionando Translate, la traduzione viene creata come bozza e aperta. La bozza parte dai dati della voce di origine e dallo stesso slug, cosa consentita perché gli slug sono univoci all’interno di ogni locale. Se la traduzione richiede uno slug localizzato, come mon-article per my-post, modifica lo slug. Adatta il contenuto, quindi salva.
Pubblicazione per locale
Ogni traduzione ha il proprio stato. Pubblica, annulla la pubblicazione o programma le traduzioni in modo indipendente. La versione francese può essere in bozza mentre la versione 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 facoltativo. Anche una route per una singola voce lo accetta quando il percorso usa uno slug; gli ID del database sono univoci a livello globale e non richiedono la disambiguazione per locale.
GET /_emdash/api/content/posts?locale=fr
GET /_emdash/api/content/posts/my-post?locale=fr
Quando 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 dei contenuti:
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 parte come bozza.
Elencare le traduzioni
Recupera tutte le traduzioni di una determinata voce:
GET /_emdash/api/content/posts/01ABC.../translations
Restituisce l’ID del gruppo di traduzione e un array di varianti di locale con i relativi ID, slug e stati.
Usare la CLI
Dopo aver autenticato la CLI, usa i suoi flag --locale sui comandi dei contenuti:
# List French posts
emdash content list posts --locale fr
# Get a specific entry in French
emdash content get posts my-post --locale fr
# Create a French translation as a draft
emdash content create posts \
--locale fr \
--translation-of 01ABC... \
--slug mon-article \
--data '{"title":"Mon article"}' \
--draft
content create richiede un input tramite --data, --file o --stdin. Pubblica dopo la creazione a meno che tu non 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, in modo che i riferimenti translationOf vengano risolti correttamente.
Scegliere quali campi sono traducibili
Ogni campo ha un’impostazione translatable (predefinita: true). Quando crei una traduzione:
- I campi traducibili vengono precompilati dal locale di origine per la modifica
- I campi non traducibili vengono copiati e mantenuti sincronizzati tra tutte le traduzioni del gruppo
In una collezione con revisioni, la pubblicazione di una voce copia nelle altre traduzioni i valori non traducibili che ha modificato, mentre il salvataggio di una bozza modifica solo quella voce. Se un’altra traduzione ha una bozza in sospeso che ha modificato uno di quei valori, la bozza mantiene il proprio valore, e la pubblicazione di 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 di riferimento è condiviso anziché sincronizzato: EmDash indicizza i suoi collegamenti in base al gruppo di traduzione, quindi ogni traduzione di una voce ha un’unica 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 supportata di EmDash lascia il locale predefinito senza prefisso:
# prefix-other-locales (Astro default)
/blog/my-post → en (default locale, no prefix)
/fr/blog/mon-article → fr
Usa getRelativeLocaleUrl da astro:i18n per aggiungere il prefisso corretto e qualsiasi mappatura personalizzata dei percorsi di 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 in /sitemap-{collection}.xml tiene conto del locale. Include le voci pubblicate delle collezioni instradabili con SEO abilitata. Le voci eliminate, le voci senza slug e le voci contrassegnate come noindex sono escluse. Ogni traduzione inclusa diventa una propria voce <url>. EmDash costruisce il suo percorso a partire dal urlPattern della collezione, quindi applica il prefisso di locale di Astro e qualsiasi mappatura path di locale personalizzata.
Le traduzioni sorelle sono collegate tra loro con alternate xhtml:link, in modo che i motori di ricerca possano servire la lingua corretta a ciascun 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 sono raggruppate per translation_group, quindi una variante di locale pubblicata compare come alternate su ogni altra variante pubblicata e indicizzabile. I locale mancanti da i18n.locales vengono omessi perché Astro non ha una route per essi. I siti con un solo locale producono una sitemap semplice, senza il namespace xhtml.
Aggiungere link hreflang all’intestazione della pagina
Gli stessi alternate appartengono al <head> di ogni pagina di contenuto. Se il tuo layout usa <EmDashHead>, questo avviene automaticamente: quando l’i18n è abilitata e il contesto della pagina include content, emette un <link rel="alternate"> per ogni traduzione sorella pubblicata — compreso 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 gli head scritti a mano, 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.rewrite("/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 a quello della sitemap:
x-defaultpunta alla variante del locale predefinito. Quando il locale predefinito non ha una traduzione pubblicata, ripiega sulla prima variante instradabile, quindi l’insieme non è mai privo dix-default.- Le sorelle non pubblicate sono escluse — le traduzioni in bozza non finiscono mai negli alternate.
- Le sorelle
noindexsono escluse. Se la voce corrente ènoindex, non viene restituito alcun alternate. - I locale non instradabili vengono scartati. Una riga il cui locale non è nei tuoi
i18n.localesconfigurati non può essere servita, e indirizzare i motori di ricerca a una 404 è peggio che non mettere alcun link. - Le voci non tradotte ricevono comunque un alternate autoreferenziale e
x-defaultquando l’i18n è abilitata, come nella sitemap. - Con l’i18n disabilitata, il risultato è vuoto e non viene eseguita alcuna query.
Gli URL sono costruiti a partire dal urlPattern della collezione e localizzati tramite la configurazione i18n di Astro. getHreflangAlternates() richiede un URL del sito assoluto. Usa siteUrl dalla chiamata o l’URL delle impostazioni del sito; senza nessuno dei due, restituisce un array vuoto perché i link hreflang devono essere assoluti.
Importare contenuti multilingue
Importa i contenuti WordPress tramite lo strumento di migrazione dell’admin — vedi Importazione di contenuti e Migrare da WordPress. Un’esportazione WXR non include la struttura di locale e gruppi di traduzione che aggiungono WPML o Polylang, quindi i contenuti importati finiscono nel tuo 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