Skip to content

Next.js + markdown の多言語対応

Next.js に組み込まれている i18n (Internationalization) サポートを使用します。

Next.js には、国際化 ( i18n ) ルーティングのサポートが組み込まれています。ロケールのリスト、デフォルト ロケール、およびドメイン固有のロケールを指定すると、Next.js が自動的にルーティングを処理します。

Config に設定を追加 Add Config

next.config.js ファイルに i18n config を追加します。 locales にサポートしたい全ての locale を記述します。 defaultLocale はデフォルトの locale を指定します。

module.exports = {
  i18n: {
    locales: ["en", "ja"],
    defaultLocale: "ja",
  },
};

国際化のルーティングには「サブパス ルーティング」と「ドメイン ルーティング」の2通りあります。「サブパス ルーティング」は /en /fr となり、「ドメイン ルーティング」は example.com example.fr となります。

当サイトではサブパスを選択しました。例えば BLOG ページ( pages/blog.js ) は

  • /blog
  • /en/blog

となります。

以上の設定でユーザーのアクセス環境に基づいて、 Next.js は locale を自動的に検出し、ページの locale が切り替わります。

そのほかの設定はNext.js の公式ドキュメント( Internationalized Routing ) で確認できます。

Switcherを作成 Create Switcher

ユーザーが任意で言語の切り替えを行えるようにボタンを設置します。

現在の locale は Next.js の useRouter で取得します。また、サイトで表示可能な locale のリストも同様に取得できます。

import Link from "next/link";
import { useRouter } from "next/router";

export const LocaleSwitcher = () => {
  const { locale, locales, asPath } = useRouter();
  return (
    <div>
      {locales &&
        locales.map((localeName) => (
          <Link key={localeName} href={asPath} passHref locale={localeName}>
            <a className={locale === localeName ? "current" : ""}>
              {localeName}
            </a>
          </Link>
        ))}
    </div>
  );
};

サイトやアプリケーション内の好きな場所で読み込みます。

ファイルの構成 File Directory Structure

当サイトは投稿ページ、固定ページを markdown ファイルで生成しています。多言語表示用にファイル名に該当の locale を追加したファイル file-name.locale.md を用意しました。

src/
└── contents/
├── pages/
| ├── about.en.md
| └── about.md
|
└── posts/
├── blog/
| ├── blog-post.en.md
| └── blog-post.md
|
└── tips/
├── tips-post.en.md
└── tips-post.md

コンポーネントを作成 Create component

当サイトの About ページ ( /src/pages/about.tsx ) を例に作成します。このファイルでは主に以下のことをしています。

  • getStaticProps でページ内容を取得
  • getPageData() で markdown ファイルのデータを取得
  • 取得した markdown ファイルの内容を HTML に変換して表示( marked を使用)

当サイトのコードの一部抜粋です。 getStaticPropslocale を取得し、 markdown ファイルを取得する getPageData() に渡しています。

import { GetStaticProps, NextPage } from "next";
import { marked } from "marked";

import { getPageData } from "@utils/post";
import { Page } from "@libs/types";

const About: NextPage<{ post: Page }> = ({ post }) => {
  return (
    <div
      className={post.data.slug}
      dangerouslySetInnerHTML={{ __html: marked(post.content) }}
    />
  )
}

export default About

export const getStaticProps: GetStaticProps = async ({ locale }) => {
  const post = await getPageData("pages", "about", locale)

  return {
    props: {
      post,
    }
  }
}

getPageData() の例です。 locale === "ja" ? postSlug : postSlug + "." + locale の部分で、デフォルトの locale (ja) であれば about.md を locale === “en” であれば about.en.md が読み込まれるようにしています。

import fs from "fs";
import path from "path";
import matter from "gray-matter";

export function getPageData(
  type: string,
  postIdentifier: string,
  locale: string = "ja"
) {
  // .md を除いたファイル名を取得
  const postSlug = postIdentifier.replace(/\.md$/, "");
  // locale に応じたファイルのパスを取得
  const filePath = path.join(
    contentsDirectory,
    type,
    `${locale === "ja" ? postSlug : postSlug + "." + locale}.md`
  );
  const readFile = fs.readFileSync(filePath, "utf-8");
  const { data, content } = matter(readFile);

  const postData = {
    slug: postSlug,
    data,
    content,
  };

  return postData;
}

投稿ページや記事の一覧ページも同様に、基本的には getStaticProps で locale を取得し、 markdown ファイルを取得する function に渡して、 locale に応じたファイルを取得して表示します。

一覧ページ

投稿ページの markdown ファイルに便宜上 locale を追加しました。

---
title: My Blog Post
locale: en
date: "2022-02-11"
category: "blog"
---

...

一覧ページ用のファイルでは getStaticProps で locale を取得して、 markdown 取得用の getPosts() に渡します。

export const getStaticProps: GetStaticProps = async ({ locale }) => {
  const tipsPosts = await getTypePosts("tips", locale)

  // 日付順に並び替え
  const sortedPosts = tipsPosts.sort((postA, postB) => postA.data.date > postB.data.date ? -1 : 1)

  return {
    props: {
      posts: sortedPosts,
    },
    revalidate: 30
  }
}

全ファイルを取得したあとに、 filter() を使用して markdown ファイル内の locale と現在の locale が一致した場合のみそのファイルを返すようにしています。

export function getPosts(type: string, locale: string = "ja") {
  // 指定のディレクトリ内のファイルを取得
  const files = getPostsDirectory(type);

  // 取得したファイルの locale を含む情報を抽出
  const allPosts = files.map((file) => {
    return getPostMeta(file, type, locale);
  });

  let filteredPosts = [];
  // 現在の locale に対応するファイルのみに絞り込み
  if (locale === "ja") {
    filteredPosts = allPosts.filter((post) => post.data.locale === "ja");
  } else {
    /** en */
    filteredPosts = allPosts.filter((post) => post.data.locale === "en");
  }

  return filteredPosts;
}

投稿ページ

投稿ページの表示ファイルはダイナミックルーティングを使用しています。

src/
└── pages/
└── tips/
└── [slug].tsx

このファイルでは主に以下のことをしています。

  • getPostPaths()getStaticPaths に必要なパスのリストを取得
  • getStaticPaths でレンダリングの必要があるパスのリストを作成
  • getPostByPath() で markdown ファイルのデータを取得
  • getStaticProps でページ内容を取得
  • 取得した markdown ファイルの内容を HTML に変換して表示( marked を使用)

[slug].tsx 内の getStaticPaths の例です。

export const getStaticPaths: GetStaticPaths = async ({ locales }) => {
  // レンダリングの必要があるパスのリストを取得
  const postPaths = getPostsPaths("tips")

  let paths: any[] = []
  // map() で必要な locale を追加したパスリストを生成
  postPaths.map((path: string) => {
    locales?.map((locale: string) => {
      paths.push({
        params: { slug: `${path}` },
        locale,
      })
    })
  })

  return {
    paths,
    fallback: "blocking"
  }
}

paths の中身は以下のようになります。

{ params: { slug: 'tips-post1' }, locale: 'ja' },
{ params: { slug: 'tips-post1' }, locale: 'en' }

markdown ファイルを取得する getPostByPath() の例です。

export function getPostByPath(
  path: any,
  type: string,
  locale: string = "ja",
) {
  let postData: Post = initialPost;
  const files = getPostsDirectory(type)

  // 表示するURLに該当するファイルのパスを取得
  const filteredFiles = files.filter(file => {
    return file.includes(path)
  })

  filteredFiles.map((file) => {
    const tempData = getPost(file, type, locale)
    // markdown 内の locale が現在の locale と一致する場合は markdown の内容を取得して返す。
    // 一致しない場合は false を返す
    if (tempData.data.locale === locale) {
      postData = getPost(file, type, locale)
    } else {
      return false
    }
  })

  return postData
}

[slug].tsx に戻り、getStaticProps で locale を取得して getPostByPath() に渡して markdown の内容を取得して、props を通して表示用コンポーネントに渡します。

export const getStaticProps: GetStaticProps = async ({ locale, params }) => {
  const post = await getPostByPath(params!.slug, "tips", locale)

  return {
    props: {
      post: post,
      slug: params!.slug,
    },
    revalidate: 600
  }

未翻訳の場合の対応

投稿ページで未翻訳の場合に <meta name="robots" content="noindex" /> が head 内に追加されるようにします。

[slug].tsx の例です。当サイトは便宜上 markdown ファイルに localized: boolean を追加して、 locale === “en” かつ localized: false の場合は noindex が追加されるようにしています。

また記事本文にも未翻訳である旨のメッセージを表示するようにしました。

import Head from "next/head";

const TipsPost: NextPage<PostProps> = ({ post, slug, tags }) => {
  return(
    <>
      <Head>
        {locale === "en" && !post.data.localized && <meta name="robots" content="noindex" />}
      </Head>
      <article>
        <p>This page is not available in English yet.</p>
      <article>
    </>
  )
}

参照