Cloudflare Pages Functionsでブラウザの言語に合わせて自動で移動させる方法
静的な多言語サイトで海外の訪問者が母国語のページへすぐ移動できるよう、Cloudflare Pages FunctionsのミドルウェアでAccept-Languageと言語選択Cookieを読み、翻訳ページへ302リダイレクトする方法をまとめます。検索エンジンのボットは移動させません。
静的な多言語サイトで言語の自動移動が必要だった理由
このブログは韓国語の原文を基本のURL(/<記事>)に置き、英語・日本語の翻訳を/en/<記事>、/ja/<記事>に置いています。すべてのページをビルド時にHTMLファイルとして作り、Cloudflare Pagesにアップロードしている静的サイトです。
問題は、検索や共有リンクから来るURLのほとんどが韓国語のURLだということでした。アメリカや日本の訪問者もまず韓国語のページを見ることになり、翻訳があることに気づかなければそのまま離れてしまいます。
普通はサーバーがリクエストを受けたときにブラウザの言語を見て、適したページを返せば済みます。しかし静的サイトはでき上がったファイルをそのまま返すだけなので、リクエストごとに判断するサーバーがありません。
そこでCloudflare Pages Functionsを使いました。Pages Functionsは、静的ファイルを返す前にリクエストを受け取って短いコードを実行できる機能です。functions/_middleware.tsファイルを作ると、サイト全体のリクエストの前で実行されるミドルウェア(リクエストとレスポンスの間に入るコード)になります1。
どの言語に移動させるかを決める順序
まずルールを決めました。訪問者を勝手に移動させるとかえって不便なこともあるので、「確かなときだけ、翻訳があるときだけ」移動させるのが原則です。
- サイトの言語選択メニューで自分で選んだ言語があれば、その言語に従います。韓国語を選んでいれば移動しません。
- 選んだことがなければ、ブラウザが送る
Accept-Languageヘッダーを見ます。このヘッダーにはブラウザ設定の優先言語の一覧が、en-US,en;q=0.9のように優先度(q値)と一緒に入っています。 - 優先言語の中にサイトが対応する言語(韓国語・英語・日本語)がなければ、英語に移動させます。
- 移動先の言語の翻訳ページが実際にあるときだけ移動させます。
そして次のリクエストはまったく移動させません。
- 検索エンジンのボット:ボットまで移動させると、韓国語の原文が検索エンジンにインデックスされなくなるおそれがあります。
Accept-Languageがないリクエスト:言語がわからないので、元のページをそのまま表示します。- すでに
/en、/ja以下のURL:利用者は翻訳ページを見ている最中です。
言語選択メニューは、選んだ言語をlang Cookieに1年間保存します。src/components/language-select.tsxのコードです。
document.cookie = `${LOCALE_COOKIE}=${target}; path=/; max-age=31536000; samesite=lax`;
functions/_middleware.tsの全コード
実際に使っているfunctions/_middleware.tsです。LOCALES(ko、en、ja)やlocalePath(言語に合ったURLを作る関数)などは、サイトのコード(src/lib/i18n.ts)と共通で使っています。
// Cloudflare Pages Function: 한국어 주소로 처음 들어온 방문자를 브라우저 언어(Accept-Language)에 맞는 /en·/ja 페이지로 보낸다.
// - 언어 select로 고른 언어(lang 쿠키)가 있으면 브라우저 언어보다 그 선택을 따른다(한국어를 골랐으면 이동하지 않는다).
// - 검색엔진 봇과 Accept-Language가 없는 요청은 이동시키지 않는다(한국어 원문이 색인되도록).
// - 이동할 번역 페이지가 없으면 한국어 페이지를 그대로 보여준다. 한국어 페이지도 없으면(404) 그 언어의 404 페이지를 보여준다.
// 정적 자산은 public/_routes.json에서 제외해 이 함수가 실행되지 않는다.
import {DEFAULT_LOCALE, LOCALE_COOKIE, LOCALES, Locale, isLocale, localePath} from "../src/lib/i18n";
type PagesContext = {
request: Request;
next: () => Promise<Response>;
env: { ASSETS: { fetch: (input: Request | string | URL) => Promise<Response> } };
};
const BOT_UA = /bot|crawl|spider|slurp|yeti|daum|bingpreview|facebookexternalhit|embedly|preview/i;
// 지원하지 않는 언어만 받는 방문자는 영어로 보낸다.
const FALLBACK_LOCALE: Locale = 'en';
function cookieLocale(request: Request): Locale | null {
const cookie = request.headers.get('Cookie') ?? '';
for (const c of cookie.split(';')) {
const [name, value] = c.trim().split('=');
if (name === LOCALE_COOKIE && isLocale(value ?? '')) return value as Locale;
}
return null;
}
function preferredLocale(acceptLanguage: string | null): Locale | null {
if (!acceptLanguage) return null;
const langs = acceptLanguage.split(',')
.map((part, index) => {
const [tag, ...params] = part.trim().split(';');
const q = params.map(p => p.trim()).find(p => p.startsWith('q='));
return {lang: tag.trim().toLowerCase().split('-')[0], q: q ? parseFloat(q.slice(2)) : 1, index};
})
.filter(l => l.lang && l.lang !== '*' && l.q > 0)
.sort((a, b) => b.q - a.q || a.index - b.index);
if (langs.length === 0) return null;
return langs.map(l => l.lang).find(isLocale) ?? FALLBACK_LOCALE;
}
export const onRequest = async ({request, next, env}: PagesContext): Promise<Response> => {
if (request.method !== 'GET') return next();
const url = new URL(request.url);
const path = url.pathname;
const otherLocales = LOCALES.filter(l => l !== DEFAULT_LOCALE);
if (otherLocales.some(l => path === `/${l}` || path.startsWith(`/${l}/`))) return next();
if (/\.[a-z0-9]+$/i.test(path)) return next();
if (BOT_UA.test(request.headers.get('User-Agent') ?? '')) return next();
const target = cookieLocale(request) ?? preferredLocale(request.headers.get('Accept-Language'));
if (!target || target === DEFAULT_LOCALE) return next();
const targetPath = localePath(target, path);
const exists = await env.ASSETS.fetch(new URL(targetPath, url.origin));
if (!exists.ok) {
const response = await next();
if (response.status !== 404) return response;
const notFound = await env.ASSETS.fetch(new URL(localePath(target, '/404'), url.origin));
return notFound.ok ? new Response(notFound.body, {status: 404, headers: notFound.headers}) : response;
}
return new Response(null, {
status: 302,
headers: {
'Location': `${targetPath}${url.search}`,
'Vary': 'Accept-Language, Cookie',
'Cache-Control': 'private, no-store',
},
});
};
ハイライトした部分を順に見ていきます。
Accept-Languageを優先度順に読む
preferredLocale関数はAccept-Languageの値をカンマで分け、各言語のq値を読んで高い順に並べます。qが同じなら書かれた順に従います。en-USのように地域が付いた値は前の部分(en)だけを使います。
並べた一覧からサイトが対応する最初の言語を選び、一つもなければ英語(FALLBACK_LOCALE)を選びます。たとえばフランス語だけを使う訪問者は英語のページへ移動します。
移動させないリクエストを先に除外する
onRequestの冒頭で、次のリクエストはすぐにnext()へ渡します。next()は「本来どおり静的ファイルを返して」という意味です。
GET以外のリクエスト- すでに
/en、/ja以下のURL .png、.xmlのように拡張子があるファイルのURL- User-Agent(リクエストしたプログラムの名前)に
bot、crawl、spiderなどの単語が含まれるリクエスト
翻訳ページがあるか確認してから302で移動する
移動先の言語が決まったら、env.ASSETS.fetchで翻訳ページのURLの静的ファイルを直接リクエストしてみます。レスポンスが正常なら翻訳があるということなので、302レスポンスでそのURLへ移動させます。
翻訳がなければ韓国語のページをそのまま表示します。韓国語のページすらなければ(存在しないURL)、その訪問者の言語の404ページを表示します。
302とキャッシュヘッダーを使う理由
移動のレスポンスでは3点に気をつけました。
302(一時的な移動):同じURLでも訪問者ごとに行き先が違います。301・308のような恒久的な移動を使うと、ブラウザや検索エンジンが「このURLはずっと向こう」と覚えてしまいます。Vary: Accept-Language, Cookie:レスポンスがこの2つのヘッダーによって変わることを伝えます。これで途中のキャッシュが英語の訪問者向けのレスポンスを韓国語の訪問者にそのまま返すことがなくなります。Cache-Control: private, no-store:移動のレスポンス自体をまったくキャッシュさせません。訪問者が後で言語選択を変えたら、すぐに反映される必要があるからです。
_routes.jsonで関数を実行するURLを減らす
ミドルウェアは基本的にすべてのリクエストで実行されます。しかし画像・フォント・スクリプトや、すでに翻訳ページである/en、/jaのURLは言語を判断する必要がありません。こうしたリクエストまで関数を実行すると、レスポンスが遅くなり呼び出し回数も増えるだけです。
そこでpublic/_routes.jsonで関数を実行しないURLを除外しました。excludeに入れたURLでは関数が実行されず、関数の呼び出し料金もかかりません2。
{
"version": 1,
"include": ["/*"],
"exclude": [
"/assets/*",
"/images/*",
"/fonts/*",
"/en",
"/en/*",
"/ja",
"/ja/*",
"/*.xml",
"/*.txt",
"/*.png",
"/*.ico",
"/*.svg",
"/*.webmanifest",
"/*.json",
"/*.data"
]
}
curlで言語ごとの移動を確認する
デプロイ後、curlで本番サイトにいろいろな条件のリクエストを送って確認しました。このときcurl -Iは使ってはいけません。-IはHEADリクエストを送りますが、ミドルウェアはGET以外のリクエストをそのまま通すので、常に200が返ります。私も最初はこれのせいでミドルウェアが動いていないと思い込みました。代わりに-s -o /dev/null -D -でGETリクエストを送り、レスポンスヘッダーだけを出力します。
英語のブラウザは英語の翻訳へ移動します。
$ curl -s -o /dev/null -D - -H 'Accept-Language: en-US,en;q=0.9' https://sncstudio.net/win-acme-validation-port
HTTP/2 302
vary: Accept-Language, Cookie
location: /en/win-acme-validation-port
cache-control: private, no-store
日本語のブラウザは日本語へ、対応していないフランス語のブラウザは英語へ移動します。
$ curl -s -o /dev/null -D - -H 'Accept-Language: ja-JP,ja;q=0.9' https://sncstudio.net/win-acme-validation-port
HTTP/2 302
location: /ja/win-acme-validation-port
$ curl -s -o /dev/null -D - -H 'Accept-Language: fr-FR,fr;q=0.9' https://sncstudio.net/win-acme-validation-port
HTTP/2 302
location: /en/win-acme-validation-port
逆に次の場合は移動せず、韓国語のページ(200)をそのまま表示します。
# 韓国語が第一言語のブラウザ
$ curl -s -o /dev/null -D - -H 'Accept-Language: ko-KR,ko;q=0.9,en;q=0.8' https://sncstudio.net/win-acme-validation-port
HTTP/2 200
# 言語選択メニューで韓国語を選んだ訪問者 (lang Cookie)
$ curl -s -o /dev/null -D - -H 'Accept-Language: en-US' -H 'Cookie: lang=ko' https://sncstudio.net/win-acme-validation-port
HTTP/2 200
# 検索エンジンのボット
$ curl -s -o /dev/null -D - -H 'Accept-Language: en-US' -A 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' https://sncstudio.net/win-acme-validation-port
HTTP/2 200
存在しないURLに来た英語の訪問者には、英語の404ページ(<html lang="en">)が表示されます。
注意点
- hreflangと一緒に使いましょう。 ボットは移動させないので、検索エンジンが翻訳の存在を知るには、各ページに
<link rel="alternate" hreflang="en" ...>のような言語別URLの指定が必要です。このブログはすべてのページにko・en・ja・x-defaultを入れています。 - 言語を切り替えるメニューは必ず置きましょう。 自動判定が外れることもあります。韓国語を読みたい海外ブラウザの利用者もいるので、自分で選んだ言語(Cookie)が常に優先されるべきです。
- URLの形をそろえておきましょう。 移動先のURLは末尾スラッシュなしの形です。URLの形をそろえる方法はCloudflare Pagesで末尾スラッシュ(/)へのリダイレクトをなくす方法にまとめました。
- ローカルで確認するには、
bunx wrangler pages dev outでfunctions/も一緒に実行できます。