In questo tutorial imparerai a costruire un blog multilingua con l'approccio Jamstack utilizzando Nuxt.js, un potente framework basato su Vue.js che supporta le modalità di rendering SPA, SSR e la generazione statica e un CMS Headless, Strapi, per memorizzare gli articoli e servirli tramite GraphQL. Per il setup di locale di Strapi puoi seguire questa guida altrimenti puoi tranquillamente utilizzare la nostra istanza in sola lettura https://strapi.lotrek.net/.
Il codice di questo tutorial è disponibile in questo repository.
La struttura del backend Strapi
Con Strapi abbiamo predisposto una struttura piuttosto banale per memorizzare i nostri Post
che contengono una referenza 1 a molti con gli elementi TransPost
dove avviene l'effettiva traduzione.
____________ ____________ | POST | | TRANS_POST | ============ ============ | published | | language | | created_at | <--(1)-------(N)-->> | title | | | | content | | | | slug | ============ ============
Se vuoi già giocare un po' con GraphQL all'interno di questo schema ed esplorare la composizione dei dati puoi andare sul GraphQL playground dedicato. Ricorda però che il focus principale di questo tutorial è Nuxt.js
, quindi ometteremo eventuali approfondimenti del backend. Inoltre grazie alla versatilità di questo framework puoi utilizzare qualsiasi backend, anche Wordpress, come abbiamo fatto noi per il nostro progetto A Good Magazine
Setup del progetto con Nuxt.js
Per cominciare assicurati di avere un ambiente con Node.js
installato almeno alla versione 12
. Installa Nuxt.js
globalmente e crea una nuova app chiamata multilangblog
npx create-nuxt-app multilangblog
Ricorda di selezionare l'opzione axios
(sarà necessario in seguito) e aggiungi un framework UI come Buefy.
Crea un client per leggere gli articoli
Installa apollo-fetch
per interrogare il nostro server Strapi con GraphQL
yarn add apollo-fetch
dopodiché crea un file index.js
nella cartella services
per wrappare tutte le richieste. Questo client implementerà 3 metodi:
getAllPostsHead
: prende tutti i post in uno specifico linguaggio, mostrando gli attributislug
etitle
.getAllPosts
: prende tutti i post in uno specifico linguaggio, mostrando gli attributislug
,title
,content
e gli articoli nelle altre lingue per riprendere lo slug tradotto.getSinglePost
: prende un singlo post specificando lingua e slug con tutti gli attributi, e gli articoli nelle altre lingue per riprendere lo slug tradotto.
import { createApolloFetch } from 'apollo-fetch' export default class BlogClient { constructor () { this.apolloFetch = createApolloFetch({ uri: `${process.env.NUXT_ENV_BACKEND_URL}/graphql` }) } getAllPostsHead (lang) { const allPostsQuery = ` query AllPosts($lang: String!) { transPosts(where: {lang: $lang}) { slug title } } ` return this.apolloFetch({ query: allPostsQuery, variables: { lang } }) } getAllPosts (lang) { const allPostsQuery = ` query AllPosts($lang: String!) { transPosts(where: {lang: $lang}) { slug title content post { published transPosts(where: {lang_ne: $lang}) { slug lang } } } } ` return this.apolloFetch({ query: allPostsQuery, variables: { lang } }) } getSinglePost (slug, lang) { const simplePostQuery = ` query Post($slug: String!, $lang: String!) { transPosts(where: {slug : $slug, lang: $lang}) { slug title content post { published transPosts(where: {lang_ne: $lang}) { slug lang } } } } ` return this.apolloFetch({ query: simplePostQuery, variables: { slug, lang } }) } }
Per far sì che BlogClient
sia accessibile ovunque noi utilizziamo il context
(ad esempio nella funzione asyncData
). Crea il file plugins/ctx-inject.js
import BlogClient from '~/services' export default ({ app }, inject) => { app.$blogClient = new BlogClient() }
e aggiungilo alla sezione plugins
nel file nuxt.config.js
export default { // ... plugins: ['~/plugins/ctx-inject.js'] }
La view principale
La struttura finale di questo blog sarà piuttosto semplice, nella homepage (/
) ci sarà una lista di articoli con il relativo link (/blog/<postslug>
). Adesso che il BlogClient
è accessibile dal context, riscrivi il componente HomePage
(pages/index.vue
) per prendere tutti gli articoli nel metodo asyncData e renderizza titolo e link per ogni articolo. Il metodo asyncData
riceve il context come primo argomento e grazie al nostro plugin scritto nel paragrafo precedente, BlogClient
sarà accessibile attraverso context.app.$blogClient
<template> <section class="section"> <div class="is-mobile"> <div v-for="post in posts" :key="post.slug"> <h2>{{ post.title }}</h2> <nuxt-link :to="{name: 'blog-slug', params:{slug: post.slug}}">Read more...</nuxt-link> </div> </div> </section> </template> <script> export default { name: 'HomePage', async asyncData ({ app }) { const postsData = await app.$blogClient.getAllPostsHead('en') return { posts: postsData.data.transPosts } }, data () { return { posts: [] } } } </script>
Aggiungi la route /blog/<postslug>
creando il componento BlogPost
(pages/blog/_slug.vue
). Installa il componente Vue Markdown per renderizzare gli articoli correttamente, visto che il contenuto dei post da Strapi arriva in formato Markdown (yarn add vue-markdown
)
<template> <section class="section"> <div class="is-mobile"> <h2>{{ post.title }}</h2> <vue-markdown>{{ post.content }}</vue-markdown> </div> </section> </template> <script> export default { name: 'BlogPost', components: { 'vue-markdown': VueMarkdown }, async asyncData ({ app, route }) { const postsData = await app.$blogClient.getSinglePost(route.params.slug, 'en') return { post: postsData.data.transPosts[0] } }, data () { return { post: null } } } </script>
Aggiungiamo le lingue
Per aggiungere il supporto all'internazionalizzazione (i18n
) installa il modulo Nuxt i18n
yarn add nuxt-i18n
Abilitalo nella sezione module
del file nuxt.config.js
{ modules: ['nuxt-i18n'] }
e configura la sezione i18n
const LOCALES = [ { code: 'en', iso: 'en-US' }, { code: 'es', iso: 'es-ES' }, { code: 'it', iso: 'it-IT' } ] const DEFAULT_LOCALE = 'en' export default { // ... i18n: { locales: LOCALES, defaultLocale: DEFAULT_LOCALE, encodePaths: false, vueI18n: { fallbackLocale: DEFAULT_LOCALE, messages: { en: { readmore: 'Read more' }, es: { readmore: 'Lee mas' }, it: { readmore: 'Leggi di più' } } } } // ... }
Adesso puoi modificare il componente HomePage
: negli elementi nuxt-link
devi usare il metodo localePath
e renderizzare la label readmore
usando la funzione $t
<nuxt-link :to="localePath({name: 'blog-slug', params:{slug: post.slug}})">{{ $t('readmore') }}</nuxt-link>
Nella funzione asyncData
puoi richiamare il metodo getAllPostsHead
del nostro client, prendendo il linguaggio corrente dalla variabile store.$i18n
del context
// .... async asyncData ({ app, store }) { const postsData = await app.$blogClient.getAllPostsHead( store.$i18n.locale ) return { posts: postsData.data.transPosts } }, // ....
Fai la stessa cosa nel componente BlogPost
usando route.params.slug
per riprendere lo slug
// .... async asyncData ({ app, route, store }) { const postsData = await app.$blogClient.getSinglePost( route.params.slug, store.$i18n.locale ) return { post: postsData.data.transPosts[0] } }, // ....
Adesso possiamo cominciare a creare un elemento fondamentale per un blog multilingua come si deve, il LanguageSwitcher
(components/LanguageSwitcher.vue
) che, come si evince dal nome, servirà per cambiare la lingua in ogni parte del sito.
<template> <b-navbar-dropdown :label="$i18n.locale"> <nuxt-link v-for="locale in availableLocales" :key="locale.code" class="navbar-item" :to="switchLocalePath(locale.code)"> {{ locale.code }} </nuxt-link> </b-navbar-dropdown> </template> <script> export default { computed: { availableLocales () { return this.$i18n.locales.filter(locale => locale.code !== this.$i18n.locale) } } } </script>
Questo componente deve essere incluso in layouts/default.vue
per renderlo disponibile sulla barra di navigazione. Il codice è molto semplice, grazie al modulo i18n di Nuxt basterà chiamare la funzione switchLocalePath
per prendere il link della pagina nelle altre lingue. Per far sì che anche i parametri dinamici della url come ad esempio lo slug
vengano passati al nostro LanguageSwitcher
è sufficente utilizzare la funzione store.dispatch nel nostro componente BlogPost
//... async asyncData ({ app, route, store }) { const postsData = await app.$blogClient.getSinglePost( route.params.slug, store.$i18n.locale ) await store.dispatch( 'i18n/setRouteParams', Object.fromEntries(postsData.data.transPosts[0].post.transPosts.map( el => [el.lang, { slug: el.slug }]) ) ) return { post: postsData.data.transPosts[0] } }, //...
Un po' di info in più sul language switcher
Ricorda di settare la variabile di ambiente NUXT_ENV_BACKEND_URL
usata dal BlogClient
con .env o direttamente (export NUXT_ENV_BACKEND_URL=https://strapi.lotrek.net
) e lancia il tutto in modalità sviluppo con
yarn dev
La generazione statica
Per generare staticamente il nostro blog multilingua lancia
yarn generate
Il risultato finale sarà una lista di tutte le route generate all'interno della cartella dist
nella root del progetto
ℹ Generating pages ✔ Generated /it/ ✔ Generated /es/ ✔ Generated / ✨ Done in 43.49s.
come si può vedere tutte le route dinamiche non vengono generate, questo perchè Nuxt.js non sa di per sé come generarle. Per aggiungere il supporto alla generazione delle route dinamiche è necessario implementare il metodo routes
all'interno della sezione generate
nel file nuxt.config.js
e ritornare una lista di oggetti contenenti la route
che si vuol generare e il payload
contenente il post.
import BlogClient from './services' // ... export default { // ... generate: { routes: async () => { const client = new BlogClient() let routes = [] let postsData = [] for (const locale of LOCALES) { postsData = await client.getAllPosts(locale.code) routes = routes.concat(postsData.data.transPosts.map((post) => { return { route: `${locale.code === DEFAULT_LOCALE ? '' : '/' + locale.code}/blog/${post.slug}`, payload: post } })) } return routes } } //... }
Dato che il payload
è disponibile nel context
, si può fare refactor di asyncData nel componente BlogPost
per prendere l'articolo direttamente context.payload
const getSinglePostFromContext = async ({ app, route, store, payload }) => { if (payload) { return payload } const postsData = await app.$blogClient.getSinglePost( route.params.slug, store.$i18n.locale ) return postsData.data.transPosts[0] } export default { name: 'BlogPost', async asyncData (context) { const singlePost = await getSinglePostFromContext(context) await context.store.dispatch( 'i18n/setRouteParams', Object.fromEntries(singlePost.post.transPosts.map( el => [el.lang, { slug: el.slug }]) ) ) return { post: singlePost } }, // ... }
Lancia yarn generate
di nuovo
ℹ Generating pages ✔ Generated /it/ ✔ Generated /es/ ✔ Generated / ✔ Generated /blog/hello-world ✔ Generated /it/blog/ciao-mondo ✨ Done in 33.82s.
Adesso Nuxt.js riesce a generere anche le route dinamiche
Puoi testare il sito generato staticamente utilizzando http-server, lanciandolo con
http-server ./dist
Un problema che si nota dopo aver effettuato la generazione statica è che asyncData
viene chiamata ogni volta durante la navigazione, il che comporta che un server che espone i contenuti attraverso la sua API come il nostro Strapi debba sempre essere online altrimenti apparirà l'errore "Failed to fetch". Questo è un problema noto al team di Nuxt.js che sta già lavorando per supportare l'opzione di full static generation nella prossima versione. Per ovviare a questo problema per adesso si può usare il plugin nuxt-payload-extractor come suggerito dallo stesso team di Nuxt.js. Una volta installato con
yarn add nuxt-payload-extractor
aggiungi il modulo in nuxt.config.js
{ modules: [ // ... 'nuxt-payload-extractor' ] }
il codice finale di getSinglePostFromContext
sarà
const getSinglePostFromContext = async ({ $axios, $payloadURL, app, route, store, payload }) => { if (payload) { return payload } let postsData = null if (process.static && process.client && $payloadURL) { postsData = await $axios.$get($payloadURL(route)) return postsData.post } postsData = await app.$blogClient.getSinglePost( route.params.slug, store.$i18n.locale ) return postsData.data.transPosts[0] }
Ovviamente è necessario aggiungere il nuxt-payload-extractor
laddove si fetchano i dati, quindi anche in HomePage
async asyncData ({ $axios, $payloadURL, app, route, store }) { if (process.static && process.client && $payloadURL) { return await $axios.$get($payloadURL(route)) } const postsData = await app.$blogClient.getAllPostsHead( store.$i18n.locale ) return { posts: postsData.data.transPosts } }
Rigenerando il sito, la funzione di asyncData non va più a chiedere i dati al server ma attraverso il payload estratto dal plugin.
Aggiungere un supporto alle custom path
A volte capita che occorre fornire delle custom path per le route dinamiche in un multilingua, ad esempio per la lingua inglese avere la url /blog/:slug
, ma per lo spagnolo fornire invece /artículos/:slug
e magari per l'italiano /articoli/:slug
. Seguendo la documentazione di nuxt-i18n bisogna specificare queste route nella sezione i18n
in nuxt.config.js
i18n { // ... parsePages: false, pages: { 'blog/_slug': { it: '/articoli/:slug', es: '/artículos/:slug', en: '/blog/:slug' } }, // ... }
Consiglio di spostare tutte le custom path in un file separato i18n.config.js
per renderle disponibili poi nella funzione generate
e nella sezione i18n
export default { pages: { 'blog/_slug': { it: '/articoli/:slug', es: '/artículos/:slug', en: '/blog/:slug' } } }
e importare questo file quindi in nuxt.config.js
import i18nConfig from './i18n.config' // ... export default { // ... i18n: { locales: LOCALES, defaultLocale: DEFAULT_LOCALE, parsePages: false, pages: i18nConfig.pages, encodePaths: false, vueI18n: { fallbackLocale: DEFAULT_LOCALE, // ... } }, // ...
Ovviamente a questo punto occorre riscrivere la funzione generate
e prendere la giusta custom path a seconda della lingua (per semplicità ho fatto un'ingenua replace)
routes: async () => { const client = new BlogClient() let routes = [] let postsData = [] for (const locale of LOCALES) { postsData = await client.getAllPosts(locale.code) routes = routes.concat(postsData.data.transPosts.map((post) => { return { route: `${locale.code === DEFAULT_LOCALE ? '' : '/' + locale.code}${i18nConfig.pages['blog/_slug'][locale.code].replace(':slug', post.slug)}`, payload: post } })) } return routes }
Prova a lanciare yarn generate
di nuovo
ℹ Generating pages ✔ Generated /blog/hello-world ✔ Generated /it/articoli/ciao-mondo ✔ Generated /es/artículos/hola-mundo ✔ Generated /es/ ✔ Generated /it/ ✔ Generated / ✨ Done in 33.82s.
come vedi le path vengono generate nella maniera corretta per tutte e 3 le lingue.
Il codice finale
In questo repository trovi il codice completo di questo tutorial, il risultato è deployato su Netlify CDN all'indirizzo https://eager-shockley-a415b7.netlify.app/. Netlify è uno dei miei servizi preferiti che fornisce cloud hosting per i siti statici, offrendo la funzione di continuous deployment, certificati SSL free, funzioni serverless e tanto altro... Il codice finale aggiunge un po' di cose rispetto al tutorial, per esempio aggiunge gli autori dei post, fa uso di qualche componente esterno che per semplicità qua è stato omesso e abilita la SEO per aggiungere i metadata necessari alle pagine (vedi la sezione SEO nella documentazione di nuxt-18n).
Un'altra cosa molto utile aggiunta nel codice finale è la sitemap, fornita dal modulo Nuxt.js Sitemap. La sitemap è facile da setuppare perchè di default già usa la funzione di generate.routes
, perciò tutte le url comprese quelle dinamiche sono automaticamente incluse nella sitemap. La configurazione è altrettanto semplice, basta aggiungere @nuxtjs/sitemap
alla fine dell'array della sezione modules
innuxt.config.js
{ modules: [ // ... '@nuxtjs/sitemap' ], }
e configurare la sezione sitemap
export default { // ... sitemap: { hostname: BASE_URL, gzip: true, i18n: DEFAULT_LOCALE } // ... }
Ci sono veramente tanti moduli e progetti nell'organizzazione Nuxt Community su Github consiglio di farci un giro!
Happy coding!