Cómo publicar posts en otros idiomas en tu blog hecho con Next.js y Markdown
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 quenext export
no aprovecha la capa de enrutamiento de Next.js. Las aplicaciones híbridas de Next.js que no utilizannext 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.
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.
- 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);
- 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
);
};
- 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();
};
- 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:
Y esto en los posts traducidos:
Para cualquier duda, podéis encontrar el repositorio de este blog aquí.