Cómo publicar posts en otros idiomas en tu blog hecho con Next.js y Markdown

Read the original posthere.

Cuando decidí empezar a aprender Next.js, vi que era una buena oportunidad hacer esta página web para practicar — aparte de que siempre he querido tener algún sitio donde ir dejando todas las cosas que me resultaban interesantes acerca de JS y React, entre otras cosas.

Una de las primeras cosas que quería mirar era el tema de la internacionalización (i18n) en Next.js. Por regla general, viene out of the box tanto para server-side rendering (SSR) como para static site generation (SSG), pero para este último hay un inconveniente y es que la internacionalización de momento no es compatible con next export, el cual es un comando de Next.js para exportar tu aplicación a HTML estático.

¿Y por qué afecta eso a la hora de crear este blog? Pues porque, aunque este blog usa SSG, necesita el comando next export para poder desplegar los archivos generados en GitHub Pages.

Esto es lo que dice la documentación oficial de Next.js:

Hay que tener en cuenta que el Enrutamiento Internazionalizado no se integra con next export ya que next export no aprovecha la capa de enrutamiento de Next.js. Las aplicaciones híbridas de Next.js que no utilizan next export son totalmente compatibles.

Dicho esto, hay varias maneras de resolver este problema como por ejemplo usar el Context de React para crear una capa de internacionalización propia, pero creo que era demasiado complicado para lo que realmente quería hacer en esta aplicación, que es ofrecer la posibilidad de tener los posts en varios idiomas — algo parecido a lo que hace Dan Abramov en su blog Overreacted.

Overreacted

Manos a la obra

Una de las primeras cosas que vamos a hacer es definir varias constantes necesarias para los idiomas en un fichero constants.ts.

export const constants = {
  // ...
  DEFAULT_LANG: 'en',
  SUPPORTED_LANGS: {
    es: 'Spanish',
    en: 'English',
  },
};

Usaremos DEFAULT_LANG para definir el idioma por defecto que se mostrará inicialmente y SUPPORTED_LANGS lo usaremos para listar los idiomas disponibles a la hora de visualizar un post.

Para empezar, la idea principal es crear un directorio por cada idioma y almacenar los posts en cada uno de ellos (manteniendo el mismo nombre para el post original y sus traducciones). Por lo tanto, podríamos tener algo así:

posts
  ├── en
  │   ├── hello-world.mdx
  │   └── this-is-another-post.mdx
  └── es
      ├── hello-world.mdx
      └── this-is-another-post.mdx

Seguidamente, vamos al lugar donde tenemos toda la lógica para obtener los posts, etc. y hacemos unos pequeños retoques.

  1. Definimos el directorio donde están los posts en el idioma por defecto definido previamente en las constantes.
const postsDirectory = path.join(process.cwd(), 'src', 'posts');
const defaultPostsDirectory = path.join(postsDirectory, constants.DEFAULT_LANG);
  1. Modificamos el método para extraer los posts y su metadata.
export const getSortedPostsData = (): PostMetadata[] => {
  // Get file names under /posts/{defaultLang}
  const fileNames = fs.readdirSync(defaultPostsDirectory);
  const allPostsData: PostMetadata[] = fileNames.map((fileName) => {
    // Remove ".md" from file name to get id
    const id = fileName.replace(/\.mdx$/, '');

    // Read markdown file as string
    const fullPath = path.join(defaultPostsDirectory, fileName);
    const fileContents = fs.readFileSync(fullPath, 'utf8');

    // Use gray-matter to parse the post metadata section
    const { content, data } = matter(fileContents);

    // Combine the data with the id
    return {
      id,
      ...data,
    };
  });

  // Sort posts by date
  return allPostsData.sort(({ date: aDate }, { date: bDate }) =>
    aDate < bDate ? 1 : -1
  );
};
  1. Modificamos el método para obtener los paths de todos los posts (necesario para getStaticPaths, lo veremos más adelante).
export const getAllPathsFromPosts = (): PostPath[] => {
  // Get the posts from the default languange
  const fileNames = fs.readdirSync(defaultPostsDirectory);
  // Get all the available languages
  const langs = fs
    .readdirSync(postsDirectory)
    .filter((lang) => lang !== Constants.DEFAULT_LANG);

  // Build all the paths using the locale (if needed) and the filename
  // Default lang URL -> /post/hello-world
  // Other lang URL -> /post/{lang}/hello-world
  return fileNames
    .map((fileName) => {
      const id = fileName.replace(/\.mdx$/, '');
      const langPaths = langs.map((lang) => ({
        params: {
          slug: [lang, id],
        },
      }));

      return [
        {
          params: {
            slug: [id],
          },
        },
        ...langPaths,
      ];
    })
    .flat();
};
  1. Modificamos el método para extraer el contenido markdown de un post específico y convertirlo a HTML.
export const getPostData = async (
  lang: string = Constants.DEFAULT_LANG,
  id: string
): Promise<Post> => {
  // Get content from /posts/{lang}/{id}.mdx file
  const fullPostsDirectory =
    lang === Constants.DEFAULT_LANG
      ? defaultPostsDirectory
      : path.join(postsDirectory, lang);
  const fullPath = path.join(fullPostsDirectory, `${id}.mdx`);
  const fileContents = fs.readFileSync(fullPath, 'utf8');

  // Use gray-matter to parse the post metadata section
  const { content, data } = matter(fileContents);

  // Use next-mdx-remote to convert markdown into HTML
  const processedContent = await serialize(content, {
    mdxOptions: {
      remarkPlugins: [remarkPrism],
    },
  });

  const otherLangs = fs
    .readdirSync(postsDirectory)
    .filter((language) => language !== Constants.DEFAULT_LANG);

  return {
    id,
    content: processedContent,
    lang,
    otherLangs,
    ...data,
  };
};

Después de todo esto, necesitamos ir a la página de Next.js donde se renderiza todo el contenido de nuestros posts y adaptarlo para poder visualizar correctamente cualquier post independientemente de en qué idioma esté. Para ello, vamos a hacer uso de una feature de Next.js llamada Dynamic Routes. Más concretamente, usaremos Optional catch all routes. Esto básicamente nos permite capturar todas las rutas de manera opcional, por lo que si cambiamos el nombre del fichero de /pages/post/[id].tsx a /pages/post/[[...slug]].tsx podremos capturar tanto la ruta /post/hello-world como la ruta /post/es/hello-world, que es lo que necesitamos para nuestro blog.

Una vez hecho esto, dentro de la página de Next.js tan solo necesitamos añadir los métodos getStaticPaths y getStaticProps.

export const getStaticPaths: GetStaticPaths = async () => {
  const paths = getAllPathsFromPosts();

  return {
    paths,
    fallback: false,
  };
};

export const getStaticProps: GetStaticProps = async ({
  params: { slug },
}: PostPath) => {
  // The slug param can have one or two parameters.
  // One parameter -> [id]
  // Two parameters -> [slug, id]
  // We need to destructure the array in a specific way to get each parameter right
  const [lang, id] = slug.length === 1 ? [undefined, slug[0]] : slug;

  const postData = await getPostData(lang, id);

  return {
    props: postData,
  };
};

Por último, queremos mostrar las traducciones del post que hay disponibles. Para ello, nos creamos un componente PostTranslations y lo usamos en [[...slug]].tsx.

/** Components */
import CustomLink from '@components/CustomLink';
/** Constants */
import Constants from '@constants/common';
/** Styles */
import styles from './PostTranslations.module.scss';

interface PostTranslationsProps {
  id: string;
  lang: string;
  otherLangs: string[];
}

const PostTranslations = ({
  id,
  lang,
  otherLangs,
}: PostTranslationsProps): JSX.Element => (
  <div className={styles.translatedPosts}>
    {lang === Constants.DEFAULT_LANG ? (
      <>
        <span className={styles.translatedPostsText}>
          This post has been translated into:
        </span>
        {otherLangs.map((otherLang) => (
          <CustomLink
            key={`${otherLang}_translation`}
            to={`/post/${otherLang}/${id}`}
            className={styles.translatedPostLink}
          >
            {Constants.SUPPORTED_LANGS[otherLang]}
          </CustomLink>
        ))}
      </>
    ) : (
      <>
        <span className={styles.translatedPostsText}>
          Read the original post
        </span>
        <CustomLink to={`/post/${id}`} className={styles.translatedPostLink}>
          here
        </CustomLink>
        <span className={styles.translatedPostsText}>.</span>
      </>
    )}
  </div>
);

export default PostTranslations;
const PostTemplate = ({
  id,
  content,
  title,
  lang,
  otherLangs,
}: Post): JSX.Element => {
  return (
    <main className="post__content">
      <h1 className="post__title">{title}</h1>
      {!!otherLangs?.length && (
        <PostTranslations id={id} lang={lang} otherLangs={otherLangs} />
      )}
      <MDXRemote {...content} />
    </main>
  );
};

De esta forma, veremos esto en el post original:

Translated posts

Y esto en los posts traducidos:

Original post

Para cualquier duda, podéis encontrar el repositorio de este blog aquí.