Crisp es una solución integral para comunicarse con los clientes.
Más información sobre lo que hacemos.

Cómo convertimos Ghost en un blog multilingüe para potenciar nuestra estrategia de SEO internacional

Aprovechar el SEO internacional es una baza poderosa para dirigirse a múltiples mercados en diferentes idiomas. Por eso decidimos internacionalizar nuestro blog, aplicando la misma estrategia que con nuestra plataforma.

Cómo convertimos Ghost en un blog multilingüe para potenciar nuestra estrategia de SEO internacional

Aprovechar el SEO internacional es una baza poderosa para dirigirse a múltiples mercados en diferentes idiomas. Por eso decidimos internacionalizar nuestro blog, aplicando la misma estrategia que con nuestra plataforma.

Aprovechar el SEO internacional es una baza poderosa para dirigirse a múltiples mercados en diferentes idiomas. Por eso decidimos internacionalizar nuestro blog, aplicando la misma estrategia que hicimos con nuestra plataforma.

Por defecto, un blog de Ghost sólo admite un idioma. Se puede cambiar entre diferentes idiomas, pero no hacer que funcionen simultáneamente. Por eso tuvimos que modificar nuestro blog para aprovechar una estrategia SEO internacional. Aquí tienes un tutorial que explica cómo lo hicimos.

SEO internacional: Un poco de contexto

Ghost tenía un tutorial en el pasado que explicaba cómo hacer que tu blog fuera multilingüe, pero faltaban muchas cosas, así que eliminaron el tutorial de su sitio web. Por eso decidimos adaptar este tutorial y añadir los pasos adicionales necesarios para hacerlo funcionar.

Para ello, adaptamos el tema Casper que estamos utilizando en Crisp.

Como ya tenemos un flujo de trabajo Gulp en nuestros proyectos en Crisp y Gulp también se utiliza para crear temas de Ghost, utilizamos Gulp para crear nuestro blog multilingüe.

En este artículo te lo explicamos:

  • Cómo crear un blog multilingüe con Ghost
  • Cómo automatizar la compilación del tema

Requisitos previos para el seo internacional

Configuración de las colecciones de contenido para tus configuraciones regionales

Para gestionar las distintas configuraciones regionales en Ghost, utilizamos las colecciones de contenido. Creamos una colección de contenido para cada idioma que queramos admitir. Todo se almacena en el archivo routes.yaml de la carpeta de configuración.

Repasemos cada sección de routes.yaml.


routes:

collections:
  /en/blog/:
    filter: "tag:[hash-en]"
    permalink: /en/blog/{slug}/
    template: index-en

  /de/blog/:
    filter: "tag:[hash-de, hash-en]"
    permalink: /de/blog/{slug}/
    template: index-de

  /es/blog/:
    filter: "tag:[hash-es, hash-en]"
    permalink: /es/blog/{slug}/
    template: index-es

  /fi/blog/:
    filter: "tag:[hash-fi, hash-en]"
    permalink: /fi/blog/{slug}/
    template: index-fi

  /fr/blog/:
    filter: "tag:[hash-fr, hash-en]"
    permalink: /fr/blog/{slug}/
    template: index-fr

  /nl/blog/:
    filter: "tag:[hash-nl, hash-en]"
    permalink: /nl/blog/{slug}/
    template: index-nl

  /pl/blog/:
    filter: "tag:[hash-pl, hash-en]"
    permalink: /pl/blog/{slug}/
    template: index-pl

  /pt/blog/:
    filter: "tag:[hash-pt, hash-en]"
    permalink: /pt/blog/{slug}/
    template: index-pt

  /ru/blog/:
    filter: "tag:[hash-ru, hash-en]"
    permalink: /ru/blog/{slug}/
    template: index-ru

  /tr/blog/:
    filter: "tag:[hash-tr, hash-en]"
    permalink: /tr/blog/{slug}/
    template: index-tr

  /zh/blog/:
    filter: "tag:[hash-zh, hash-en]"
    permalink: /zh/blog/{slug}/
    template: index-zh

taxonomies:
  tag: /en/blog/tag/{slug}/
  author: /en/blog/author/{slug}/

Rutas
Esta sección te permite añadir rutas personalizadas. Hay varios casos de uso para esto, pero no estamos utilizando esta sección aquí para este tutorial, por lo que no vamos a explicar más.

Colecciones
Colecciones lista todas las colecciones de contenido que hemos creado. Cada colección de contenido tiene una plantilla diferente, pero la detallaremos más adelante en este tutorial.

Utilizamos etiquetas internas, que son etiquetas no visibles para los lectores del blog pero que permiten organizar las entradas. Para crear una etiqueta interna, empieza con hash- y añade el slug que quieras. Por ejemplo, hash-es. Se mostrará como #es.
Gracias a estas etiquetas internas, podemos filtrar para mostrar sólo las entradas de blog correctas en la localización adecuada. Nosotros elegimos filtrar con la configuración regional correcta (tr en /tr/) e inglés, para que las entradas en inglés estén disponibles en todo el blog.

Taxonomías
La última, taxonomías, gestiona los enlaces permanentes de etiquetas y autores. Como no necesitamos tener estas páginas en varios idiomas, las mantenemos en la sección de taxonomías y utilizamos el inglés como configuración regional por defecto.

Instalando los paquetes npm necesarios

Como dijimos antes, usamos Gulp para gestionar nuestro proyecto, por lo que necesitamos varios paquetes npm en nuestro gulpfile.js. Aquí están los que deberías instalar vía npm o yarn antes de empezar el resto del tutorial:

  • gulp: Para gestionar todo nuestro flujo de trabajo
  • gulp_rename: Para renombrar plantillas
  • gulp-replace: Para crear los archivos localizados a partir de nuestras plantillas
  • gulp-zip: Para comprimir nuestro tema
  • del: Para limpiar el proyecto
  • yargs: Sólo es necesario si quieres gestionar las configuraciones de desarrollo y producción

Explicación de cada plantilla

Hay tres tipos de archivos:

  • Los que modificamos una sola vez y copiamos cada vez que compilamos el tema.
  • Los que ponemos en cada traducción de nuestra configuración regional.
  • Los que construimos para cada locale en cada compilación, y basados en plantillas.

Aquí está la lista de archivos que modificamos una vez y sólo copiamos cada vez que compilamos:

  • author.hbs
  • error-404.hbs
  • error.hbs
  • index.hbs
  • post.hbs
  • tag.hbs

Dentro de cada uno, sólo cambiamos {{!< default}} por {{!< default-es}} y cambiamos las importaciones parciales a en cada vez ya que el inglés es nuestro idioma por defecto. Esta importación por ejemplo {{\>"es/post-card"}}. Esto nos permite tener estas páginas disponibles por defecto en https://crisp.chat/en/.

Por cierto, el índice y el puesto no se utilizan, pero sólo se mantiene por lo que el tema es verificado por gscan sin ningún problema.

Parciales

  • leer-tiempo.hbs
  • tiempo.hbs

Por ejemplo, el read-time.hbs tiene este aspecto

{{#match locale "de"}}
  {{reading_time secondes="<1 minute lesen" minute="1 minute lesen" minutes="% minuten lesezeit"}}
  
{{else match locale "es"}}
  {{reading_time secondes="<1 min de lectura" minute="1 min de lectura" minutes="% min de lectura"}}
  
{{else match locale "fi"}}
  {{reading_time secondes="<1 minuutti lukemista" minute="1 minuutti lukemista" minutes="% minuten lezen"}}
  
{{else match locale "fr"}}
  {{reading_time secondes="<1 min de lecture" minute="1 min de lecture" minutes="% min de lecture"}}
  
{{else match locale "nl"}}
  {{reading_time secondes="<1 één minuut lezen" minute="1 één minuut lezen" minutes="% minuten lezen"}}
  
{{else match locale "pl"}}
  {{reading_time secondes="<1 minuta czytania" minute="1 minuta czytania" minutes="% minuty czytania"}}
  
{{else match locale "pt"}}
  {{reading_time secondes="<1 ata de leitura" minute="1 ata de leitura" minutes="% atas de leitura"}}
  
{{else match locale "ru"}}
  {{reading_time secondes="<1 минутное чтение" minute="1 минутное чтение" minutes="% минуты чтения"}}
  
{{else match locale "tr"}}
  {{reading_time secondes="<1 bir dakikalık okuma" minute="1 bir dakikalık okuma" minutes="% okuma dakikaları"}}
  
{{else match locale "zh"}}
  {{reading_time secondes="<1 一分钟阅读" minute="1 一分钟阅读" minutes="% 阅读分钟"}}
  
{{else}}
  {{reading_time secondes="<1 min read" minute="1 min read" minutes="% min read"}}
{{/match}}

Tuvimos que traducir el ayudante reading_time de Ghost para que fuera compatible con nuestro idioma. Hicimos lo mismo con el ayudante de tiempo.

Luego tenemos los archivos que modificamos e hicimos plantillas, a partir de los cuales creamos cada archivo localizado.

  • partials/index.hbs
  • partials/post-card.hbs
  • custom-{idioma}.hbs
  • default-{localidad}.hbs
  • index-{localidad}.hbs

{locale} es para en, fr, tr, ... y {language} es cuando quiero usar el nombre del idioma en lugar de su locale.

Aquí está el índice parcial por ejemplo

{{!-- The main content area --}}

<main id="site-main" class="site-main outer">
  <div class="inner">
    <div class="post-feed">
      {{#get "posts" filter="tag:[hash-{locale},hash-en]" include="tags,authors" order="published_at desc" page=pagination.page}}
        {{#foreach posts}}
          {{!-- The tag below includes the markup for each post - partials/post-card.hbs --}}
          {{> "{locale}/post-card"}}
        {{/foreach}}
      {{/get}}
    </div>
  </div>
</main>

El custom-{language}.hbs es realmente interesante. A menudo se utiliza para crear un diseño personalizado para un puesto, pero lo estamos utilizando aquí para nuestros diferentes idiomas.

Cada idioma tiene su archivo custom-{language}.hbs, como los otros archivos. Nos permite traducir el contenido necesario, importar los parciales correctos, e importar el default-{locale}.hbs correcto.

Para el custom-{language}.hbs estamos utilizando "language" en lugar de "locale". De esta manera, cuando seleccionamos la plantilla en el editor Ghost, vemos Inglés en el menú desplegable, en lugar de En.

💡
Aclaración: Tenemos un paquete npm que gestiona nuestra cabecera, pie de página y otras secciones, por lo que no incluimos código para ello ya que es personalizado a nuestras necesidades. Hemos eliminado el código relacionado con la conversión de la cabecera, pie de página y dos secciones de .jade a .hbs y luego traducir el contenido de los archivos JSON.

Scripting de todo el proceso de construcción con Gulp

El problema con nuestro sistema es que creamos varios archivos bastante similares para cada sección del tema. Es entonces cuando Gulp es muy útil para ayudarnos a automatizar este aspecto del tema.

Obteniendo la configuración

Para empezar, necesitábamos un archivo de configuración que almacenara todas las configuraciones regionales que queríamos soportar. Todo se almacena en common.json en la carpeta config.

{
  "LANGS": [
    {
      "code": "en",
      "name": "English",
      "language": "English",
      "country": "United States"
    },

    {
      "code": "fr",
      "name": "Français",
      "language": "French",
      "country": "France"
    },

    {
      "code": "de",
      "name": "Deutsch",
      "language": "German",
      "country": "Deutschland"
    },

    {
      "code": "es",
      "name": "Español",
      "language": "Spanish",
      "country": "España"
    },
    
    {
      "code": "fi",
      "name": "Suomi",
      "language": "Finnish",
      "country": "Suomi"
    },
    
    {
      "code": "nl",
      "name": "Dutch",
      "language": "Dutch",
      "country": "Nederland"
    },

    {
      "code": "pt",
      "name": "Português",
      "language": "Portuguese",
      "country": "Brazil"
    },

    {
      "code": "tr",
      "name": "Türk",
      "language": "Turkish",
      "country": "Türkiye"
    },

    {
      "code": "pl",
      "name": "Polskie",
      "language": "Polish",
      "country": "Polska"
    },

    {
      "code": "ru",
      "name": "Pу́сский",
      "language": "Russian",
      "country": "Russia"
    },
    
    {
      "code": "zh",
      "name": "中国人",
      "language": "Chinese",
      "country": "中国"
    }
  ],
}

A continuación, obtenemos esta configuración y la almacenamos en una variable, CONFIG, que estamos utilizando en nuestro gulpfile.js.

/*
  Copy existing CSS files
*/
var copy_css = function() {
  return gulp.src(
    "./src/assets/css/*.css",
  )
  .pipe(
    gulp.dest(
      "./build/assets/css/"
    )
  );
}


/*
  Copy existing JS files
*/
var copy_js = function() {
  return gulp.src(
    "./src/assets/js/*.js"
  )
  .pipe(
    gulp.dest(
      "./build/assets/js/"
    )
  );
}


/*
Copy main content
*/
var copy_main_content = function() {
  return gulp.src(
    [
      "./src/*.hbs",
      "!./src/assets/templates",
      "!./src/assets/templates/**",
      "LICENSE",
      "package.json"
    ]
  )
  .pipe(
    gulp.dest(
      "./build/"
    )
  );
}


/*
Copy existing HBS files
*/
var copy_partials_hbs = function() {
  return gulp.src(
    "./src/partials/**/*.hbs",
  )
  .pipe(
    gulp.dest(
      "./build/partials/"
    )
  );
}

Copiando Activos Existentes

Estamos almacenando nuestro tema compilado en una carpeta, pero algunos archivos no se modifican, sólo se copian a esta carpeta de compilación.

Los activos se almacenan en una carpeta de activos en src, por lo que tenemos varios scripts copiando su contenido a la carpeta de construcción.

/*
  Copy existing CSS files
*/
var copy_css = function() {
  return gulp.src(
    "./src/assets/css/*.css",
  )
  .pipe(
    gulp.dest(
      "./build/assets/css/"
    )
  );
}


/*
  Copy existing JS files
*/
var copy_js = function() {
  return gulp.src(
    "./src/assets/js/*.js"
  )
  .pipe(
    gulp.dest(
      "./build/assets/js/"
    )
  );
}


/*
Copy main content
*/
var copy_main_content = function() {
  return gulp.src(
    [
      "./src/*.hbs",
      "!./src/assets/templates",
      "!./src/assets/templates/**",
      "LICENSE",
      "package.json"
    ]
  )
  .pipe(
    gulp.dest(
      "./build/"
    )
  );
}


/*
Copy existing HBS files
*/
var copy_partials_hbs = function() {
  return gulp.src(
    "./src/partials/**/*.hbs",
  )
  .pipe(
    gulp.dest(
      "./build/partials/"
    )
  );
}

Creación de archivos parciales localizados

El siguiente script ejecuta cada configuración regional en common.json y crea una carpeta con todos los archivos parciales necesarios para cada configuración regional. A continuación, sustituye en el nombre de archivo y dentro del archivo, cada {locale} con la configuración regional correcta.

/*
  Create partials hbs files for each locale
*/
var create_partials_hbs = function() {
  CONFIG.LANGS.map(function(_config_lang) {
    gulp.src(
      "./src/assets/templates/partials/*.hbs"
    )
    .pipe(
      gulp_replace(
        "{locale}",
        _config_lang.code
      )
    )
    .pipe(
      gulp.dest(
        `./build/partials/${_config_lang.code}`
      )
    );
  });

  return Promise.resolve();
}

Creación de los archivos principales

Este script hace lo mismo que el anterior, pero esta vez para los ficheros principales, como default.hbs e index.hbs

/*
  Delete build
*/
var delete_build = function() {
  return del(
    [
      "./build",
      "./**.hbs",
      "./assets/**",
      "./partials/**",
      "!./src/**"
    ]
  );
}


/*
  Delete zipped theme
*/
var delete_zip = function() {
  return del(
    "./dist"
  );
}


Limpieza del proyecto

Estos dos scripts borran las carpetas build y dist.

/*
  Delete build
*/
var delete_build = function() {
  return del(
    [
      "./build",
      "./**.hbs",
      "./assets/**",
      "./partials/**",
      "!./src/**"
    ]
  );
}


/*
  Delete zipped theme
*/
var delete_zip = function() {
  return del(
    "./dist"
  );
}

Cómo comprimir el tema

Estas dos funciones comprimen nuestro tema y lo guardan en una carpeta dist.

/*
  Zip theme in dist folder
*/
var zipper = function() {
const filename = require('./package.json').name + '.zip';
  return gulp.src(
    "./build/**"
  )
  .pipe(
    gulp_zip(filename)
  )
  .pipe(
    gulp.dest(
      "./dist"
    )
  )
}

Al final, lo gestionamos todo con tres líneas de comando:

  • gulp --development: nos permite probar nuestro tema localmente con las URLs locales
  • gulp: compila el tema sin scripts ni archivos inútiles, dentro de una carpeta de compilación
  • gulp zip: comprime nuestra carpeta de compilación para crear una carpeta de tema comprimida, lista para ser cargada en el panel de Ghost

Gestión de redirecciones mediante proxy inverso en Nginx

Utilizamos proxies inversos en Nginx para gestionar nuestras nuevas URLs y redirigir las URLs antiguas a las nuevas, para evitar cualquier gran impacto en SEO. He aquí una vista previa de la misma.

root ../crisp-web;

error_page 404 =404 /build/templates/not_found.en.html;

# Enforce URLs to end with a trailing slash
# Notice: the blog administration panel does not support trailing slashes for some URLs

rewrite "^/(?!([a-z]{2})(/blog/ghost/))([^\.]+[^/])$" http://$server_name/$3/ permanent;

location = / {
    rewrite ^/$ http://$server_name/$crisp_web_lang/ permanent;
}

location ~ "^/([a-z]{2})/blog/ghost/.*" {
    rewrite "^/([a-z]{2})/blog/ghost/(.*)$" /ghost/$2 break;

    proxy_pass http://127.0.0.1:2368;

    client_max_body_size 12m;

    proxy_cookie_path ~*^/.* /;

    # Do not buffer responses, otherwise large files might be written 		to a disk cache while being streamed to client
    proxy_buffering off;

    proxy_connect_timeout 5s;
    proxy_read_timeout 5s;
    proxy_send_timeout 5s;

    proxy_http_version 1.1;

    proxy_set_header Accept-Encoding "";

    sub_filter_types application/json;
    sub_filter_once off;
    sub_filter http://$server_name/content/  http://$server_name/static/blog/content/;

    include proxy_http_headers.conf;
    include error_pages_text_rules.conf;
}

location ~ "^/([a-z]{2})/blog/(.*).(xml|xsl)" {
    rewrite "^/([a-z]{2})/blog/(.*).(xml|xsl)" /$2.$3 break;

    proxy_pass http://127.0.0.1:2368;

    proxy_connect_timeout 5s;
    proxy_read_timeout 5s;
    proxy_send_timeout 5s;

    proxy_http_version 1.1;

    proxy_set_header Accept-Encoding "";

    sub_filter_types text/xml text/xsl;
    sub_filter_once off;
    sub_filter http://$server_name/sitemap-pages.xml 	     		http://$server_name/en/blog/sitemap-pages.xml;
    sub_filter http://$server_name/sitemap-posts.xml  http://$server_name/en/blog/sitemap-posts.xml;
    sub_filter http://$server_name/sitemap-authors.xml http://$server_name/en/blog/sitemap-authors.xml;
    sub_filter http://$server_name/sitemap-tags.xml http://$server_name/en/blog/sitemap-tags.xml;
    sub_filter http://$server_name/sitemap.xml http://$server_name/en/blog/sitemap.xml;
    sub_filter //$server_name/sitemap.xsl //$server_name/en/blog/sitemap.xsl;
    sub_filter http://$server_name/content/ http://$server_name/static/blog/content/;

    include proxy_http_headers.conf;
    include error_pages_text_rules.conf;
}

location ~ "^/([a-z]{2})/blog/.*" {
    proxy_pass http://127.0.0.1:2368;

    proxy_connect_timeout 5s;
    proxy_read_timeout 5s;
    proxy_send_timeout 5s;

    proxy_http_version 1.1;

    proxy_set_header Accept-Encoding "";

    sub_filter_types text/xml;
    sub_filter_once off;
    sub_filter http://$server_name/content/ 				    http://$server_name/static/blog/content/;
    sub_filter /static/blog/public/cards /static/blog/static/blog/public/cards;

    include proxy_http_headers.conf;
    include error_pages_text_rules.conf;
}

location ~ /not_found/$ {
    return 404;
}

location ~ ^/(static/blog/.*) {
    rewrite ^/static/blog/(.*)$ /$1 break;

    # Use blog assets
    proxy_pass http://127.0.0.1:2368;

    # Do not buffer responses, otherwise large files might be written 		to a disk cache while being streamed to client
    proxy_buffering off;

    proxy_connect_timeout 5s;
    proxy_read_timeout 10s;
    proxy_send_timeout 10s;

    proxy_http_version 1.1;

    expires 1d;

    include proxy_http_headers.conf;
    include error_pages_text_rules.conf;
}

location ~ ^/((robots\.txt)|(sitemap\.xml))$ {
    rewrite  ^/(.*)$  /build/web/$1;
}

location ~ ^/((humans\.txt)|(favicon\.ico)|(opengraph\.png)|((tile|favicon(-\d+x\d+)?|apple-touch-icon(-\d+x\d+)?)\.png)|(\.well-known/.*)) {
    rewrite  ^/(.*)  /web/$1;
}

location ~ "^/(?!((static/.*)|(sitemap\.xml)|(robots\.txt)|(humans\.txt)|(favicon\.ico)|(opengraph\.png)|(([a-z]{2})/.*)|((tile|favicon(-\d+x\d+)?|apple-touch-icon(-\d+x\d+)?)\.png)|(\.well-known/.*)))" {
    # English is default for the pages accessed via root URLs (eg: /features/ => /en/features/)
    rewrite  ^/(.+)/$  http://$server_name/en/$1/ permanent;
}

include error_pages_text_locations.conf;
include cors_http.conf;

Novedades al publicar una entrada de blog

Hemos querido que la transición al nuevo sistema de blog sea lo más sencilla posible. Por eso sólo tienes que añadir dos cosas:

  • En primer lugar, añade la etiqueta interna correspondiente a la entrada de tu blog. Por ejemplo, añade la etiqueta interna #pt para una entrada en portugués.
  • En segundo lugar, selecciona la plantilla personalizada correcta en el menú desplegable. Esto permite a Ghost saber que toda la interfaz de usuario debe estar en este idioma (cabecera, pie de página, botones, enlaces, ...).

Y ya está. Tu entrada de blog ya puede ser publicada y estará en la parte localizada correcta de tu blog.

Conclusión

Mientras lees este artículo, el nuevo blog ya está en producción. Ahora tenemos un blog que es más fácil de mantener y que podemos escalar para mejorar nuestro SEO internacional, así como la capacidad de publicar más idiomas en el futuro, como nuestro sitio web principal.

Gracias a este proyecto, ahora podemos empezar a dirigirnos a nuevos mercados con contenidos en un idioma específico.







¿Listo para mejorar su compromiso con el cliente?