Internacionalización de un blog usando Gatsby y Strapi

14 min
Internacionalización de un blog usando Gatsby y Strapi

Blog personal

Mi página web personal donde añado nuevos artículos sobre ingeniería, sistemas, economía y psicología.

ReactGatsbyGraphQLHTMLSASS

Ya he hablado en muchas ocasiones de Gatsby en este blog. Me parece una de las mejores maneras de crear páginas estáticas como blogs o páginas web corporativas. Sin embargo, un aspecto al cual no se le puede sacar mucho potencial de forma nativa es la internacionalización. La gente de Gatsby escribió un post donde mostraban cómo convertir tu sitio web en una página multilingüe, pero hay varias cosas que se han dejado en el tintero a las que vengo a dar un poco de luz.

No obstante, antes de empezar quiero comentar algunos aspectos del contexto en el que me encuentro:

  • Sólo me interesa traducir la página al inglés. Aunque voy a intentar que todo quede lo más preparado posible para expandir a nuevos idiomas, en algunas partes será necesaria hacer refactorización.
  • Utilizo una API de contenido, concretamente Strapi, por lo si habéis entrado a este post usando otra fuente de datos, algunas cosas de este artículo no os servirán.

Estructuras de las URLs

Uno de los mayores problemas a los que nos enfrentamos al crear un sitio web multilingüe es la elección de la estructura de URLs que tendrá la página web. Algunas personas creen que esto no es importante, pero una mala estructura perjudica gravemente el SEO del sitio y dificulta que los usuarios se muevan entre los diferentes idiomas.

Como en mi caso no estoy creando un blog desde cero, sino que parto de mi página web personal en la que llevo trabajando durante meses, ya tengo una estructura predefinida para los contenidos en español, más concretamente la siguiente:

╔═══════════════════════╗
║  Páginas estáticas    ║
╠═══════════════════════╣
║ /                     ║
║ /contacto/            ║
║ /politica-privacidad/ ║
║ /blog                 ║
║ /proyectos            ║
║ /libros               ║
╚═══════════════════════╝

╔═══════════════════════════════╗
║           Artículos           ║
╠═══════════════════════════════╣
║ /desarrollo/ionic-calculadora ║
║ /lean/producto-minimo-viable  ║
║ /ingenieria/kpi               ║
╚═══════════════════════════════╝

╔══════════════╗
║  Categorías  ║
╠══════════════╣
║ /desarrollo/ ║
║ /ingenieria/ ║
║ /lean/       ║
╚══════════════╝

╔═════════════════════════╗
║        Proyectos        ║
╠═════════════════════════╣
║ /proyectos/wateralo/    ║
║ /proyectos/profitchart/ ║
║ /proyectos/justtasks/   ║
╚═════════════════════════╝

Esta estructura no se puede romper (al menos no si fuera estrictamente necesario) porque el coste que tendría a nivel SEO sería muy importante. Este blog tiene alrededor de 100 páginas indexadas y no me gustaría tener que hacer redirecciones para todas ellas. Partiendo de esta idea, todavía podemos seguir usando algunas de las opciones más usadas para la internacionalización de sitios webs.

ISO 639

Ambas opciones hacen uso de los códigos de idioma y localización. Estos códigos siguen la norma ISO 639, una nomenclatura estandarizada de idiomas de todo el mundo. Existen varias variaciones de la norma, pero la más común y la más usada es la que utiliza dos caracteres para definir el idioma. Adicionalmente, se pueden diferenciar idiomas de una misma región, como por ejemplo el inglés de UK y el inglés de USA.

En este caso, no será necesaria una diferenciación por zona geográfica, por lo que usaremos el código de dos caracteres designado para el inglés: en.

Exponiendo ambas opciones

La primera opción (1) es la más sencilla de todas: introducir antes de cada URL el código del nuevo idioma en el que se va a renderizar la página, en este caso el inglés. Por ejemplo, la página /contacto/, se transformaría en /en/contacto. ¿Sencillo, verdad? Pero tiene un inconveniente, parte de la URL está en español cuando el contenido estará en inglés. ¿Cómo puede afectar esto? Principalmente a nivel SEO, aunque debido a su facilidad de implantación, es una opción a tener en cuenta.

La segunda opción (2) es algo más compleja. A parte de introducir el código del idioma al comienzo de la URL, añadimos el resto del slug en el nuevo idioma. De esta manera, la página /contacto/, se transformaría en /en/contact. En este caso estaríamos solucionando el problema que comentamos en el caso anterior, pero su implantación será mucho más compleja.

Así quedarían comparadas las dos opciones anteriores:

╔════════════════════════════════╦═══════════════════════════════════╦══════════════════════════════════╗
║            Original            ║           i18n Opción 1           ║          i18n Opción 2           ║
╠════════════════════════════════╬═══════════════════════════════════╬══════════════════════════════════╣
║ /                              ║ /en/                              ║ /en/                             ║
║ /contacto/                     ║ /en/contacto/                     ║ /en/contact/                     ║
║ /blog                          ║ /en/blog/                         ║ /en/blog/                        ║
║                                ║                                   ║                                  ║
║ /desarrollo/ionic-calculadora/ ║ /en/desarrollo/ionic-calculadora/ ║ /en/development/ionic-calc/      ║
║ /lean/producto-minimo-viable/  ║ /en/lean/producto-minimo-viable/  ║ /en/lean/minimum-viable-product/ ║
║ /ingenieria/kpi/               ║ /en/ingenieria/kpi/               ║ /en/engineering/kpi/             ║
║                                ║                                   ║                                  ║
║ /desarrollo/                   ║ /en/desarrollo/                   ║ /en/development/                 ║
║ /ingenieria/                   ║ /en/ingenieria/                   ║ /en/engineering/                 ║
║ /lean/                         ║ /en/lean/                         ║ /en/lean/                        ║
║                                ║                                   ║                                  ║
║ /proyectos/wateralo/           ║ /en/proyectos/wateralo/           ║ /en/projects/wateralo/           ║
║ /proyectos/profitchart/        ║ /en/proyectos/profitchart/        ║ /en/projects/profitchart/        ║
║ /proyectos/justtasks/          ║ /en/proyectos/justtasks/          ║ /en/projects/justtasks/          ║
╚════════════════════════════════╩═══════════════════════════════════╩══════════════════════════════════╝

Eligiendo la opción que mejor se ajusta

En mi caso, como me interesa bastante el posicionamiento en buscadores, opto por la segunda opción: incluir el código de lenguaje seguido del slug en inglés para las URLs de páginas en dicho idioma. Eso significa que el resto del artículo estará basado en esta decisión. No obstante, en caso de duda, la segunda opción es por lo general más correcta que la primera, pero requiere de mucho más trabajo de implantación.

Así quedarán algunas de las rutas de la página web:

/
├── contacto/
├── blog/
├── proyectos/
│   └── wateralo/
├── desarrollo/
│   └── calculadora-ionic/
└── en/
    ├── contact/
    ├── blog/
    ├── projects/
    │   └── wateralo/
    └── development/
        └── ionic-calc/

Cambios en la API

Una vez analizada la nueva estructura de la aplicación, nos damos cuenta que necesitamos slugs nuevos para todos los tipos de contenido que se muestran en la web: artículos, categorías y proyectos. Por esa misma razón, se deben hacer cambios en Strapi para que se adapta a las nuevas necesidades de internacionalización de nuestra aplicación.

En este apartado voy a resumir brevemente todos los pasos, por lo que te recomiendo que consultes más información en la documentación de Strapi.

Instalar y activar plugin de i18n

Abrimos una terminal en la carpeta en la que se localiza el proyecto de Strapi y ejecutamos el siguiente comando:

# Si usas yarn
yarn strapi install i18n

# Si usas npm
npm run strapi install i18n

Ahora iniciamos Strapi en entorno de desarrollo utilizando el siguiente comando:

strapi develop

Iniciamos sesión en el entorno de desarrollo desde la dirección http://localhost:1337/admin y comprobamos que en la página de plugins aparezca el módulo de internacionalización.

Por último, en la Configuración de Strapi accedemos a Internationalization y añadimos los idiomas pertinentes, aclarando con es el idioma por defecto (el idioma que se mostrará si no se indica nada y el que se asociará a todos los contenidos que hayas creado hasta ahora).

Configurar los tipos de contenidos

Por defecto, ningún tipo de contenido tiene activa la internacionalización. Para ponerla en marcha, debemos editar el tipo de contenido desde la pestaña de Configuración Avanzada en el Creador de Tipos de Contenido (Enable localization for this Content-Type).

activar-i18n-strapi-min.png

Una vez realizados todos estos cambios, podemos desplegar de nuevo la aplicación en entorno de producción. Si no hay ningún problema, todos los servicios que obtengan datos de esta API deberán seguir funcionando de forma correcta, ya que si no se especifica nada en la petición, los datos que se devuelven son los del idioma por defecto.

Traduciendo contenidos existentes

A partir de ahora, todas los nuevos contenidos que se crean se harán en el idioma por defecto seleccionado en el primer sub-apartado. No obstante, se pueden añadir traducciones utilizando el nuevo apartado de Internacionalización que aparece en la barra lateral del editor:

anadir-idioma-contenido-min.png

Una vez realizadas todas las peticiones pertinentes, dejamos de lado Strapi para comenzar a trabajar con Gatsby, tecnología a la que vamos a dedicar mucho más tiempo y donde se desarrollarán la mayoría de mejores de internacionalización de la página web.

Paginas generadas estáticamente

En Gatsby las pueden generarse bien de forma manual o bien de forma automática. Las páginas generadas de forma manual se almacenan en la carpeta pages y generan su slug en base al nombre del fichero que creamos. Esta carpeta funciona de la misma forma que la raiz de un proyecto en HTML, es decir, podemos crear sub-carpetas para ir creando diferentes ramificaciones en la estructura de la web.

En este caso, interesa crear una carpeta llamada en, donde se albergarán todas las páginas de contacto, blog, página principal y proyectos. La estructura de la carpeta pages quedaría de la siguiente forma:

.
└── pages/
    ├── 404.js
    ├── blog.js
    ├── contacto.js
    ├── index.js
    ├── libros.js
    ├── politica-privacidad.js
    ├── proyectos.js
    └── en/
        ├── blog.js
        ├── contact.js
        ├── index.js
        └── projects.js

Al procesar la anterior arquitectura de ficheros, se crearán las siguientes páginas en nuestro sitio (el orden coincide con el de arriba):

/404
/blog
/contacto
/
/libros
/politica-privacidad
/proyectos
/en/blog
/en/contact
/en/
/en/projects

Cada una de estas páginas se desarrollará en el idioma pertinente, por lo que para evitar duplicidad de código, recomiendo el uso de componentes, cuya adaptación a la internacionalización explicaré más adelante. Además, almacenando los ficheros de esta manera cumplimos la estructura que hemos fijado en un apartado anterior.

Para evitar el nombre de los componentes de las páginas sea el mismo en algunas páginas como blog, que se escribe de la misma forma en ambos idiomas, recomiendo denominarlos utilizando el código ISO que le corresponde. Por ejemplo, BlogEs para la página en español y BlogEn para la página en inglés. No confundir esto con el nombramiento del resto de componentes, tema que trataremos más adelante. En este caso solo hablamos del componente que se alojará en los ficheros de páginas estáticas.

Sin embargo, muchas de las páginas se crean de forma dinámica, como es caso de los posts de los artículos, las páginas de los proyectos o las secciones de las categorías. En el siguiente apartado trataremos este tema en más profundidad.

Páginas generadas dinámicamente

En el sitio web hay tres tipos de páginas que se generan de forma automática:

  • Las páginas de artículos
  • Las páginas de categorías con sus artículo correspondientes y paginación
  • Las páginas de proyectos

Hasta ahora suponía un reto bastante importante obtener toda esta información para mostrársela al usuario (la paginación de las categorías fue una de las tareas más arduas que tuve que desarrollar). Pero ahora que entra en juego la internacionalización, las cosas se vuelven algo más complicadas.

Generación automáticas de slugs

Lo primero con lo quería lidiar es con la generación de slugs. Crear funciones auxiliares para generar estos slugs es mi prioridad número uno, ya que conforme el proyecto escale y las apariciones de enlaces aumente, no me gustaría tener que encontrarme con enlaces mal formados que no llevan a ninguna parte. Para ello, en la carpeta helpers añado un fichero denominado createSlug.js con las siguientes tres funciones:

export const createProjectSlug = (projectSlug, lang) => {
  return `/${(lang === 'es') ? 'proyectos' : 'en/projects'}/${projectSlug}/`
}

export const createPostSlug = (postSlug, categorySlug, lang) => {
  return `/${(lang === 'es') ? '' : 'en/'}${categorySlug}/${postSlug}/`
}

export const createCategorySlug = (categorySlug, lang) => {
  return `/${(lang === 'es') ? '' : 'en/'}${categorySlug}/`
}

De esta manera, cuando por ejemplo quiera enlazar la página de un post con la de su categoría, solamente tendré que hacer uso de la función correspondiente y evitar que si en futuro cambio la estructura de los slugs (algo nada recomendable, por cierto), todos los enlaces sigan funcionando con normalidad:

import {createCategorySlug} from '../helpers/createSlug.js'

return (
	<Link to={createCategorySlug(categorySlug, lang)}>
		Desarrollo
	</Link>
)

Generación de nodos

Ya sabréis que desde el fichero gatsby-node.js se gestionan todas las páginas que se generan de forma dinámica cuyos datos se obtienen usando GraphQL de las diferentes fuentes que podemos configurar en nuestro sitio.

En el caso que nos concierne hay tres funciones para generar cada uno de los tres tipos de páginas dinámicas que ya he comentado anteriormente: posts, categorías y proyectos. La estructura básica de la creación de páginas dinámicas para internacionalización es la siguiente:

exports.createPages = ({ actions, graphql }) => {
  const { createPage } = actions;

  const getPosts = makeRequest(graphql, `
    {
      allStrapiPost {
        edges {
          node {
            id
						locale
						slug
						category {
							slug
						}
						localizations {
							id
							locale
						}
        }
      }
    }
    `).then(result => {
    result.data.allStrapiPost.edges.forEach(({ node }) => {
      createPage({
        path: `/${(node.locale === 'es') ? '' : 'en/'}${node.category.slug}/${node.slug}/`,
        component: path.resolve(`src/templates/post.js`),
        context: {
          id: node.id,
					locale: node.locale,
          otherLangPostId: (_node$localizations$ = node.localizations[0]) === null || _node$localizations$ === void 0 ? void 0 : _node$localizations$.id
        },
      })
    })
  });

	// Generate categories and projects pages...

  return Promise.all([
    getPosts
  ])
};

En el caso anterior, se automatiza la creación de posts. Algunos de estos atributos seleccionados en la consulta GraphQL se pasan por el pageContext de la página, mientras que otros se utilizan para la generación del slug:

  • id: se utiliza en la page query para obtener los datos del post a renderizar.
  • locale: se utiliza para la generación del slug y para conocer el idioma que estamos tratando (en los siguientes apartados entenderéis su verdadera utilidad)
  • slug: se utiliza para la generación del slug
  • category: {slug}: se utiliza para la generación del slug
  • localizations: {id, locale}: en el caso de existir un node en otro idioma, se manda a la página creada para poder hacer cambios entre páginas (en los siguientes apartados entenderéis su verdadera utilidad).

ℹ️ Respecto al último elemento, de momento solo está pensado para utilizar dos idiomas en la página web (inglés y español, por ejemplo). En el caso de que hubiese decidido optar por la primera opción cuando elegí la nueva estructura de URLs, no sería necesario, ya que podríamos movernos entre páginas de diferentes idiomas cambiando el prefijo de lenguaje, ya que el resto del slug no cambia.

Propagación del idioma por los componentes

Si probamos a ejecutar ahora nuestro sitio web, nos daremos cuenta de que hay partes que se encuentran en idiomas diferentes en páginas del nuevo idioma implantado, en mi caso el inglés. Esto se debe a que algunos de estos textos se obtienen ya de la API en el idioma deseado, pero hay otros que se han escrito en crudo en los componentes que no varían al cambiar la página de idioma. Por ejemplo, en mi caso, la tarjeta proyectos que relaciono en los posts tienen las frases “Sitio web” o “Repositorio” tanto si el post está en inglés como si está en español (siguiente imagen). Otro caso es el que hemos comentado antes de los componentes de las páginas estáticas como la página Home.

componentes-diferentes-idiomas-min.png

En este caso debemos hacer una propagación del idioma a través de todo el árbol de componentes. Para ello, todos los componentes deberán incorporar una nueva propiedad llamada lang. En la mayoría de ocasiones, un componente hijo la recibirá de una propiedad llamada de la misma forma en su componente padre, como se puede ver en el siguiente código:

export const FatherComponent = ({lang}) => {
	return (
		<ChildComponent lang={lang} />
	)
}

Cuando lleguemos al nodo padre en el árbol de componentes, por ejemplo, el componente de la página de un artículo, la propiedad lang ya no la podemos heredar de ningún otro componente. Es en este momento cuando cuando deberemos obtener esta propiedad bien de la API o bien introducirla nosotros a mano. Por ejemplo, en el componente contacto.js, donde se encuentra la página de contacto en Español, funcionaría de la siguiente forma:

export const ContactPageEs = ({lang}) => {
	return (
		<h1>Contacto</h1>
		<ContactForm lang="es"} />
		<Map lang="es"} />
	)
}

Para traducir textos, podemos usar el operador ternario de JS para mostrar uno u otro idioma dependiendo del atributo lang. A continuación, un ejemplo de uso:

export const ProjectTemplate = ({lang}) => {
	return (
		<span>
		{
			(lang === 'es')
				? 'Más información'
				: 'Show details'
		}	
		</span>
	)
}

El operador ternario solo permite intercambiar entre dos lenguajes, lo cual es perfecto para mi caso, pero que claramente no escala si queremos usar más idiomas. Como solución, se podría utilizar un objeto JSON con diferentes objetos que representen los diferentes textos y cuyas propiedades son las traducciones a cada uno de los idiomas.

Selector de idioma

Para poder indicarle al selector de idioma las diferentes páginas equivalentes en otros lenguajes que tiene la página actual, he diseñado un objeto especial que nos ayudará con esta tarea.

Objeto i18n

El objeto i18n contiene toda la información que el selector necesita. Esta compuesto por dos propiedades:

  • actual: marca el idioma actual de la página
  • languages: array que indica las diferentes páginas alternativas, incluida la actual, con la siguiente estructura:
    • lang: idioma de la página alternativa
    • url: dirección de la página alternativa

Para generar este objeto de forma más sencilla, he creado varias funciones en un fichero de JS dentro de la carpeta helpers que genera objetos i18n para páginas en idiomas (como es mi caso).

/*
EJEMPLO
{
	actual: 'es',
	languages: [
		{
			lang: 'es',
			url: '/proyectos/
		},
		{
			lang: 'en',
			url: '/en/projects/
		}
	]
}
*/

export const getI18nForPage = (locale, currentSlug, otherLocale, otherSlug) => {
  const i18n = {
    actual: locale,
    languages: []
  }
  if (otherLocale) {
    i18n.languages.push(
      {
        lang: locale,
        url: currentSlug
      }
    )
    i18n.languages.push(
      {
        lang: otherLocale,
        url: otherSlug
      }
    )
  }
  return i18n
}

Propagación del objeto i18n

Este objeto i18n se propaga de forma similar a la propiedad lang que se comentado en el apartado anterior. Entonces, ¿por qué no propagamos esta objeto, el i18n, en vez de solamente el lenguaje actual? Pues bien, razón de peso no hay ninguna, ambas opciones serían válidas, lo único es que muchos de los componentes no necesitan saber de la existencia de páginas equivalentes en otro idioma.

Aun así, hay algunos componentes que sí lo necesitan saber, como es el header y el componente SEO. En el header se localizará el selector de idioma que nos permitirá cambiar entre la misma página en diferentes idiomas. Por otro lado, en el componente SEO nos añadirá meta enlaces que serán de interés para los buscadores.

En mi caso, el componente header se localiza dentro del componente layout, por lo que hay que propagar el objeto desde este último hacia el header.

const IndexPageEn = () => {

  const i18n = getI18nForPage('en', '/en/', 'es', '/')

  return (
    <Layout i18n={i18n}>
      <Seo title="Juan Otálora | Computer Engineer" i18n={i18n}/>
      <h1>Homepage</h1>
    </Layout>
  )
}

Pero vamos a explicarlo paso a paso:

Selector en la barra de navegación

Una vez que el header recibe el objeto i18n, lo demás es componente de React sencillo, aunque hay que tener en cuenta varios aspectos:

  • Si no hay idiomas alternativos, el selector no se muestra
  • El idioma actual debe aparecer como seleccionado en el selector
  • En el menú de selección deben aparecer todos los idiomas menos el idioma actual

Todos estos puntos se tienen en cuenta en el siguiente código preparado para implantarlo en una barra de navegación de Bootstrap:

export const LanguageSelect = ({i18n}) => {
  const {actual, languages} = i18n
  return (
    <>
      {
        languages && languages.length > 0 &&
        <ul className="navbar-nav">
          <li className="nav-item dropdown">
            <Link className="nav-link dropdown-toggle" to="#" id="navbarDropdownMenuLink" data-toggle="dropdown"
                  aria-haspopup="true" aria-expanded="false">
              {actual.toUpperCase()}
            </Link>
            <div className="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
              {
                languages.filter(l => l.lang !== actual).map(dest => (
                  <Link className="dropdown-item" key={dest.lang} to={dest.url}>
                    {dest.lang.toUpperCase()}
                  </Link>
                ))
              }
            </div>
          </li>
        </ul>
      }
    </>
  )
}

Cambios en la cabecera

La cabecera de un documento HTML contiene información importante sobre la página web que el usuario está visitando. Entre toda esa información, a nosotros nos interesan dos para la internacionalización: el atributo lang y los enlaces alternativos.

Para poder modificar estos valores, debemos modificar el componente SEO, basado en módulo de node react-helmet. Puedes buscar este tipo de componentes por Internet o bien revisando el repositorio de esta página web en GitHub.

Atributo lang

El atributo lang, añadido en la etiqueta raíz del documento, indica al navegador el idioma que se está utilizando en dicha página. En nuestro caso, esta propiedad se puede obtener del objeto i18n, más concretamente de la propiedad actual.

function Seo({description, meta, title, index, i18n}) {

	// [...]	

  return (
    <Helmet
      htmlAttributes={{
        lang: i18n.actual
      }}
    />
  )

	// [...]

}
export default Seo;

Enlaces alternativos en la cabecera con hreflang

Los enlaces alternativas le indican a los buscadores las diferentes direcciones alternativas en otros idiomas en las que se encuentra disponible esa página. Para cumplir con la normativa de la mayoría de buscadores, se deben incluir todos los enlaces a todas las páginas alternativas, incluida la página actual que el usuario está visitando. Por ejemplo:

<link rel="alternate" hreflang="en" href="www.juanoa.com/en">

En nuestro caso, como disponemos del objeto i18n, es bastante sencillo construir dichas etiquetas:

function Seo({description, meta, title, index, i18n}) {
  const {site} = useStaticQuery(
    graphql`
      query {
        site {
          siteMetadata {
            siteUrl
          }
        }
      }
    `
  );
  const alternateLinks = []
  i18n.languages.forEach(langLink => {
    alternateLinks.push({
      href: `${site.siteMetadata.siteUrl}${langLink.url}`,
      rel: 'alternate',
      hreflang: langLink.lang
    })
  })

	// [...]

  return (
    <Helmet
      link={alternateLinks}
    />
  )
}

export default Seo;