dois:pontos

Criando um site multilíngue com Next.js - Parte 2

- 8 min

Se você caiu nesta segunda parte e não viu a primeira, sugiro que dê uma olhada antes. Para não deixar o artigo muito longo, optei por dividi-lo em duas partes. Na parte anterior vimos como traduzir os termos que aparecem na tela. Nesta parte, trataremos da criação e listagem do conteúdo para os idiomas. Sem mais delongas, lá vamos nós!

Conteúdo em Markdown para os idiomas

A estrutura do arquivo segue o exemplo abaixo:

---
lang: pt
title: "Artigo em português"
slug: artigo
date: "2020-07-12"
category: post
description: "Lorem ipsum dolor sit amet consectetuer adispiscing elit"
---

## Lorem

Lorem ipsum dolor sit amet consectetuer adispiscing elit.

Caso não conheça o Markdown, este cabeçalho que está entre --- chama-se "frontmatter". Com ele, passamos informações que serão usadas para a listagem e exibição do conteúdo. Abaixo uma descrição breve do que cada campo faz:

  • lang: ISO do idioma usado no conteúdo.
  • title: título do artigo.
  • date: data do artigo, no formato AAAA-MM-DD. Note que ela está entre aspas, caso contrário o Next.js dá um erro.
  • description: resumo do artigo na página de listagem de artigos.
  • category: categoria do artigo.

Você tem a liberdade de criar seus próprios campos neste cabeçalho, como tags e outras coisas. Para o exemplo aqui citado, isto já é o suficiente.

Biblioteca para ler os artigos em Markdown

Como você pode ver, arquivos Markdown são a base dos conteúdos. Para ler estes arquivos e convertê-los em HTML, três pacotes precisam ser instalados: Remark e Remark-HTML e Gray Matter. Este último lê o frontmatter de arquivos *.md.

Para instalar:

yarn add remark remark-html gray-matter
npm install --save remark remark-html gray-matter

Esta parte foi fácil, no entanto, criar a lista deles não é algo tão simples assim. Primeiro segui o tutorial1 que o pessoal do Next.js fez, mas tive de fazer algumas adaptações para contemplar a possibilidade de guardar os arquivos em pastas diferentes, por idioma. Abaixo, segue o código comentado.

import fs from "fs"
import path from "path"
import matter, { GrayMatterFile } from "gray-matter"
import remark from "remark"
import html from "remark-html"

// Diretório usado para ler os arquivos markdown
const postsDirectory = path.resolve(process.cwd(), "posts")

// Retorna uma lista com os arquivos nos diretórios e
// subdiretórios como ['en/filename.md']
function getAllPostFileNames(directoryPath, filesList = []) {
  const files = fs.readdirSync(directoryPath)

  files.forEach((file) => {
    if (fs.statSync(`${directoryPath}/${file}`).isDirectory()) {
      filesList = getAllPostFileNames(`${directoryPath}/${file}`, filesList)
    } else {
      filesList.push(path.join(path.basename(directoryPath), "/", file))
    }
  })

  // Filtro para incluir somente arquivos *.md
  // Se não usar isto, vem até .DS_Stores
  const filteredList = filesList.filter((file) => file.includes(".md"))
  return filteredList
}

// Coleta as informações dos arquivos e os ordena por data
export function getSortedPostData() {
  // Pega a lista de arquivos *.md no diretório de posts
  const fileNames = getAllPostFileNames(postsDirectory)

  // Usa o gray-matter para coletar as informações do arquivo
  const allPostsData = fileNames.map((fileName) => {
    const id = fileName.split("/")[1].replace(/\.md$/, "")
    const fullPath = path.join(postsDirectory, fileName)
    const fileContents = fs.readFileSync(fullPath, "utf-8")
    const frontMatter: GrayMatterFile<string> = matter(fileContents)

    return {
      id,
      ...(frontMatter.data as {
        lang: string
        date: string
        category: string
      }),
    }
  })

  // Ordena as informações coletadas por data
  return allPostsData.sort((a, b) => {
    if (a.date < b.date) {
      return 1
    } else {
      return -1
    }
  })
}

// Separa o nome dos arquivos e do idioma.
export function getAllPostIds() {
  // Pega a lista de arquivos *.md no diretório de posts
  const fileNames = getAllPostFileNames(postsDirectory)

  // Separa as partes "en" e "filename" de cada ['en/filename.md']
  // e retorna-os como parâmetros para uso posterior no Next
  return fileNames.map((fileName) => ({
    params: {
      id: fileName.split("/")[1].replace(/\.md$/, ""),
      lang: fileName.split("/")[0],
    },
  }))
}

// Disponibiliza os dados para o post informado.
export async function getPostData(id) {
  const fullPath = path.join(postsDirectory, `${id}.md`)
  const fileContents = fs.readFileSync(fullPath, "utf-8")
  const frontMatter = matter(fileContents)

  const processedContent = await remark().use(html).process(frontMatter.content)

  const contentHtml = processedContent.toString()

  return {
    id,
    ...(frontMatter.data as { date: string; title: string }),
    contentHtml,
  }
}

Para quem já usou o Gatsby, este arquivo é o equivalente ao arquivo gatsby-node.js. Ele disponibiliza os dados dos arquivos para serem exibidos no Next.js.

Listando o conteúdo

O Next.js utiliza uma maneira própria de roteamento. Ao contrário do Gatsby, onde qual você define as rotas das páginas de listagem no arquivo gatsby-node.js, você utiliza a própria estrutura de pastas.

Para ter uma URL site.com/idioma/post/artigo, basta criar os diretórios seguindo esta estrutura, dentro da pasta /pages que já usamos para criar as páginas do site.

Se fizemos algo como o exposto acima, teríamos o mesmo resultado, porém usando componentes em React em vez dos arquivos .md. No final teríamos diversos componentes e uma pasta para cada idioma. Porém, esta não é a melhor maneira de fazer este trabalho.

Faz muito mais sentido deixar os arquivos de conteúdo em Markdown e usar algo dinâmico para ler este conteúdo e gerar as páginas estáticas. O Next.js consegue usar os nomes de pastas e arquivos para demonstrar uma parte dinâmica da rota, através do uso de colchetes.

img À direita a forma que o Next.js organiza rotas dinâmicas

Em vez de fazer a estrutura da esquerda, ficamos com a versão mais enxuta à direita. Neste exemplo, o arquivo para listagem de arquivos é o articles.tsx. Ele está dentro da pasta /[lang] que irá informar ao Next.js que a variável "lang" será usada na URL: site.com/[lang]/articles. Este [lang] será substituído por pt ou en de acordo com o idioma a ser exibido. Eis o código do arquivo de listagem de conteúdo:

import { useState } from "react"
import { NextPage, GetStaticProps, GetStaticPaths } from "next"
import Link from "next/link"

import Layout from "../../components/Layout"
// Importação da função que lista os artigos por data
import { getSortedPostData } from "../../lib/posts"
import useTranslation from "../../intl/useTranslation"

interface Props {
  locale: string
  allPostsData: {
    date: string
    title: string
    lang: string
    description: string
    id: any
  }[]
}

const Post: NextPage<Props> = ({ locale, allPostsData }) => {
  const { t } = useTranslation()

  // Artigos filtrados por idioma
  const postsData = allPostsData.filter((post) => post.lang === locale)

  // Paginação
  const postsPerPage = 10
  const numPages = Math.ceil(postsData.length / postsPerPage)
  const [currentPage, setCurrentPage] = useState(1)
  const pagedPosts = postsData.slice(
    (currentPage - 1) * postsPerPage,
    currentPage * postsPerPage
  )

  // Opções de exibição da data
  const dateOptions = {
    year: "numeric",
    month: "long",
    day: "numeric",
  }

  return (
    <Layout className="posts" title={t("articles")}>
      <section className="page-content">
        <h1>{t("articles")}</h1>
        {/* Listagem dos artigos */}
        {pagedPosts.map((post) => (
          <article key={post.id} className="post">
            <Link href={`/[lang]/post/[id]`} as={`/${locale}/post/${post.id}`}>
              <a>
                <h3>{post.title}</h3>
              </a>
            </Link>
            <time>
              {new Date(post.date).toLocaleDateString(locale, dateOptions)}
            </time>
            {post.description && <p>{post.description}</p>}
          </article>
        ))}

        {/* Área de paginação */}
        {numPages > 1 && (
          <div className="pagination">
            {Array.from({ length: numPages }, (_, i) => (
              <button
                key={`pagination-number${i + 1}`}
                onClick={() => setCurrentPage(i + 1)}
                className={currentPage === i + 1 ? "active" : ""}
              >
                {i + 1}
              </button>
            ))}
          </div>
        )}
      </section>
    </Layout>
  )
}

// Captura as informações necessárias para a página estática
export const getStaticProps: GetStaticProps = async (ctx) => {
  // Todos os artigos do site
  const allPostsData = getSortedPostData()

  // Retorna as propriedades usadas no componente principal: a página
  return {
    props: {
      locale: ctx.params?.lang || "pt", // Captura o idioma do [lang]
      allPostsData,
    },
  }
}

// Gera o arquivos estáticos na exportação
export const getStaticPaths: GetStaticPaths = async () => {
  // Todos os idiomas suportados devem estar listados em paths.
  // Caso não for informado, a página não será gerada.
  return {
    paths: [{ params: { lang: "en" } }, { params: { lang: "pt" } }],
    fallback: false,
  }
}

export default Post

Como a intenção é gerar arquivos estáticos, usei a função getStaticProps() para capturar as informações e a getStaticPaths para informar ao sistema o caminho para as páginas que serão geradas.

Página de conteúdo

Mais uma página com o nome especial, para informar uma rota dinâmica. Nesta o parâmetro será o id do arquivo, que é capturado pela função getAllPostIds() do arquivo lib/posts, por isso, o nome deste componente será [lang]/posts/[id].tsx. Abaixo o conteúdo do arquivo.

import { GetStaticProps, GetStaticPaths, NextPage } from "next"

/* - getAllPostIds: Pega o id do arquivo, ou seja, o nome do arquivo 
    markdown sem a extensão *.md
   - getPostData: Coleta a informação de um único artigo pelo id informado.
 */
import { getAllPostIds, getPostData } from "../../../lib/posts"
import Layout from "../../../components/Layout"

interface Props {
  locale: string
  postData: {
    lang: string
    title: string
    slug: string
    date: string
    category: string
    contentHtml: string
  }
}

const Post: NextPage<Props> = ({ postData, locale }) => {
  const { title, contentHtml } = postData

  return (
    <Layout title={title}>
      <article className="post-content">
        <h1>{title}</h1>
        <div
          className="post-text"
          dangerouslySetInnerHTML={{ __html: contentHtml }}
        />
      </article>
    </Layout>
  )
}

// Como na página de listagem, passa a informação capturada para as propriedades da página
export const getStaticProps: GetStaticProps = async ({ params }) => {
  // Coleta os dados do post "en/filename"
  const postData = await getPostData(`/${params.lang}/${params.id}`)

  return {
    props: {
      locale: params?.lang || "pt", // Captura a [lang] da URL
      postData,
    },
  }
}

// Usa a getAllPostIds para informar quais páginas a serem geradas na exportação para arquivos estáticos.
export const getStaticPaths: GetStaticPaths = async () => {
  const paths = await getAllPostIds()

  return {
    paths,
    fallback: false,
  }
}

export default Post

Isto já é o suficiente para uma página simples de blog.

Conclusão

Para fazer estes dois artigos, usei a referência que deixei abaixo. Foi a mais próxima daquilo que queria. Porém, ali existem certas coisas que não foram tão úteis para mim, ou geravam complicações desnecessárias para o tamanho do projeto. Note que não existe uma dependência de biblioteca externa para tradução, o que é bastante interessante. Se tiver qualquer dúvida ou sugestão deixe um comentário, ficarei feliz com o seu feedback!

Deixei um link para o repositório do projeto no Github para consulta, caso queira ver o código completo.

Links

Comentários