dois:pontos

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

- 7 min

Se você caiu nesta terceira parte e não viu a primeira e tampouco a segunda, sugiro que dê uma olhada antes. Na parte anterior, tratamos da criação e listagem do conteúdo para os idiomas e encerramos o projeto por ali.

Porém, alguns comentaram que seria interessante poder adicionar slugs traduzidas também nos idiomas, por exemplo: em inglês a página "sobre" abriria no endereço site.com/en/about e sua versão correspondente abriria em site.com/pt/sobre. Neste artigo, eu mostro como podemos criar tal funcionalidade. Comecemos!

Aproveitando o ensejo...

Nos artigos anteriores a função para troca de idiomas foi implementada, mas ao atualizar a página, voltava-se ao idioma padrão, o que causava um certo incômodo. Este comportamento não é o ideal, portanto é importante solucionar este problema. Felizmente, não é difícil de implementar, bastando poucas linhas de código.

Local Storage

Local Storage é uma forma que o JavaScript nos proporciona para gravar informações no navegador do usuário, de modo que elas estejam disponíveis em uma próxima visita. Muitos a usam para gravar uma autenticação simples ou para salvar opções, como modos claro e escuro, por exemplo.

A lógica usada aqui não difere daquela de uma troca de tema, apenas o objetivo é que muda, dado que apenas o idioma será salvo. Para isso pequenas modificações em dois arquivos são necessárias. Os arquivos são: o componente Header e o contexto para idiomas LanguageProvider. Se você caiu aqui de paraquedas, não viu os dois artigos anteriores e não entendeu nada, eu bem que avisei no início do artigo! Vai lá e confere o conteúdo e depois volte aqui!

Eis o código para o componente Header:

import { useContext } from "react"
import { useRouter } from "next/router"

import Navigation from "../Navigation"
import Logo from "../Logo"
import { LanguageContext, locales } from "../../intl/LanguageProvider"

interface Props {
  className?: string
  children?: React.ReactNode
}

const Header: React.FC<Props> = ({ className, children }) => {
  const headerClass = className || "header"
  const [locale, setLocale] = useContext(LanguageContext)
  const router = useRouter()

  function handleLocaleChange(language: string) {
    if (!window) {
      return
    }

    const regex = new RegExp(`^/(${locales.join("|")})`)
    localStorage.setItem("lang", language) // Essa é a linha que grava o idioma!
    setLocale(language)

    router.push(router.pathname, router.asPath.replace(regex, `/${language}`))
  }

  return (
    <header className={headerClass}>
      <Logo link={`/`} />
      <Navigation />
      {children}
      <div className="lang">
        <button onClick={() => handleLocaleChange("en")}>EN</button>
        <button onClick={() => handleLocaleChange("pt")}>PT</button>
      </div>
    </header>
  )
}

export default Header

No Header, foi usado o método localStorage.setItem('lang', language) para definir a escolha de idioma ao clicar no botão correspondente. O que este método faz é basicamente gravar uma chave 'lang' com a sigla da linguagem escolhida. Você pode conferir isto na área Application do inspetor do seu navegador, na seção Local Storage.

Já o LanguageProvider ficou da seguinte forma:

import { createContext, useEffect, useState } from "react"

export const defaultLocale = "pt"
export const locales = ["pt", "en"]
export const LanguageContext = createContext([])

export const LanguageProvider: React.FC = ({ children }) => {
  const [locale, setLocale] = useState("pt")

  useEffect(() => {
    if (!window) {
      return
    }
    // Captura a informação de idioma salvo pelo componente Header
    const language = localStorage.getItem("lang") || locale
    setLocale(language)
  }, [locale])

  return (
    <LanguageContext.Provider value={[locale, setLocale]}>
      {children}
    </LanguageContext.Provider>
  )
}

Aqui o método localStorage.getItem('lang') captura a informação salva da escolha de idioma, e a aplica caso exista. Agora ao atualizar a página, a língua que você escolheu continua lá.

Agora sim, vamos criar as páginas com endereços no idioma...

Nada impede que se crie arquivos na pasta /pages, com o título que se deseja, como /kontakt.tsx para uma página de contato em alemão. Irá funcionar perfeitamente, mas convenhamos, não é a melhor forma de fazer esse trabalho. O ideal seria disponibilizar uma maneira de que páginas sejam criadas dinamicamente, com um modelo padrão, alterando o conteúdo e o endereço conforme o idioma.

Se você parar para pensar, coisa semelhante é feita com a nossa área de posts neste projeto. Para isso basta modificar a biblioteca que criamos para os posts (/lib/posts.ts) para contemplar nossas novas páginas traduzidas. Para evitar código duplicado, em vez de criar um arquivo /lib/pages.ts com praticamente o mesmo conteúdo de /lib/posts, resolvi unificar tudo em uma biblioteca que chamei de lib/files.ts.

O conteúdo deste arquivo é o que se segue:

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

const postsDirectory = path.resolve(process.cwd(), "content", "posts")
const pagesDirectory = path.resolve(process.cwd(), "content", "pages")

// Coleta todos os nomes de arquivos em pastas especificadas
// com a estrutura ['en/filename.md']
export function getAllFileNames(directoryPath: string, filesList = []) {
  const files = fs.readdirSync(directoryPath)

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

  const filteredList = filesList.filter((file) => file.includes(".md"))
  return filteredList
}

// Ordena os posts por data
export function getSortedPostData() {
  const fileNames = getAllFileNames(postsDirectory)

  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
      }),
    }
  })

  return allPostsData.sort((a, b) => {
    if (a.date < b.date) {
      return 1
    } else {
      return -1
    }
  })
}

//  IDs para posts ou páginas
export function getAllIds(type = "post") {
  const dir = type === "page" ? pagesDirectory : postsDirectory
  const fileNames = getAllFileNames(dir)

  return fileNames.map((fileName) => ({
    params: {
      id: fileName.split("/")[1].replace(/\.md$/, ""),
      lang: fileName.split("/")[0],
    },
  }))
}

// Coleta dados do arquivo markdown e os disponibiliza para uso
export async function getContentData(id: string, type = "post") {
  const dir = type === "page" ? pagesDirectory : postsDirectory
  const fullPath = path.join(dir, `${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,
  }
}

Criei um argumento type em algumas das funções que serão utilizadas por posts e páginas. Isso se deve ao fato de que este argumento identifica o diretório no qual os arquivos serão lidos. Por padrão, eu deixei configurado para sempre procurar por posts. Visto que o nome do arquivo mudou e as funções também, é necessário atualizar os imports nos arquivos que usam da biblioteca.

Modelo para a página dinâmica

Eis outra 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 getAllIds() do arquivo lib/files. O nome deste arquivo será [lang]/[id].tsx. Abaixo segue o código completo do arquivo.

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

import { getAllIds, getContentData } from "../../lib/files"
import Layout from "../../components/Layout"

interface PageProps {
  locale: string
  pageData: {
    lang: string
    title: string
    slug: string
    date: string
    category?: string
    contentHtml: string
  }
}

const SitePage: NextPage<PageProps> = ({ pageData }) => {
  const { title, contentHtml } = pageData

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

export const getStaticProps: GetStaticProps = async ({ params }) => {
  // Aqui é o o argumento tipo "page" para que o Next.js
  // busque os arquivos de página e não os posts.
  const pageData = await getContentData(`/${params.lang}/${params.id}`, "page")

  return {
    props: {
      locale: params?.lang || "pt",
      pageData,
    },
  }
}

export const getStaticPaths: GetStaticPaths = async () => {
  // Aqui é o o argumento tipo "page" para que o Next.js
  // busque os arquivos de página e não os posts.
  const paths = getAllIds("page")

  return {
    paths,
    fallback: false,
  }
}

export default SitePage

Com este arquivo, já é possível ter o suporte a páginas para o site, criadas através do Markdown. Os arquivos em markdown seguem a seguinte estrutura:

---
lang: pt
title: "Sobre"
---

Site made to showcase the creation of a bilingual website using Next.js. The tutorial is in an article on my blog. Feel free to view the source code, fork it, or even use it in your projects.

Para organizar melhor os arquivos, criei um diretório chamado /content na raiz do projeto, e dentro dele mais outros dois: posts e pages. Estes receberão os arquivos markdown dentro dos diretórios referentes a cada idioma suportado no site. Com o código aqui apresentado, a criação das páginas é totalmente automatizada e baseada nesta estrutura.

Conclusão

Acredito que aqui já temos um exemplo bem funcional de um site multilíngue feito em Next.js. Você pode criar conteúdo para diversos idiomas e dá a opção do usuário escolher a que preferir para usar no site.

Quaisquer comentários, sugestões e dúvidas são bem vindos. Deixe seu comentário abaixo. Também disponibilizei o link para o repositório do projeto completo no GitHub para consulta, caso queira ver o código completo. Caso tenha algum erro, pode deixar sua issue lá também.

Abraço!

Links

Comentários