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.
À 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.