はじめに
Tooleek は、登録なしで使える無料Webツールを集めたサイト群です。PDF結合、画像圧縮、QRコード、タイマー、タイピング、ナンプレなどがあります。
この記事では、ハブ+ツール14個の計15サイトを、サーバーを持たず静的HTMLだけで動かしている構成と、多言語化(韓国語・英語・日本語・中国語簡体字・スペイン語)の仕組みをまとめます。アクセス数などの数字の話はなく、構成だけの話です。
全体構成
- ハブ
tooleek.com+ ツール14個(サブドメイン。例:pagefold.tooleek.com) - すべて Cloudflare Pages の静的サイト。サーバーコードもDBもありません
- 各サイトに
build.mjsが1つあり、実行するとdist/に素のHTMLを書き出します - 共通部品は
core/にまとめ、各サイトのフォルダにコピーして使います
ad-tool-sites/
core/ # layout.mjs, base.css, common.js, network.mjs, words.mjs
pagefold/ # build.mjs, i18n.txt, assets/, dist/
imglite/ # build.mjs, i18n.txt, i18n-long.txt, assets/, dist/
...(ツールごとに同じ形)
core/ の中身はこうなっています。
| ファイル | 役割 |
|---|---|
layout.mjs |
ページの外枠、SEOタグ、hreflang、サイトマップ、OG画像、翻訳エンジン |
base.css / common.js
|
共通CSSと、ブラウザ側の小さなヘルパー(翻訳、localStorage、乱数など) |
network.mjs |
フッターに出す姉妹サイト一覧(名前と説明を5言語の配列で持つ) |
core/ を各フォルダにコピーしているので、どのサイトもフォルダ単体でビルドできます。
ビルド: build.mjs はHTMLを書くだけ
PDFツール(pagefold)の build.mjs から、翻訳まわりを抜き出すとこうなります。
import { render, write, sitemap, i18n, i18nReport, i18nClient, loadDict /* ほか */ } from './core/layout.mjs';
const OUT = 'dist';
const DICT = loadDict(); // i18n.txt を読む
const TR = i18n(DICT); // 言語ごとの翻訳関数を作る
// ...ページを組み立てて書き出す...
sitemap(OUT, site.base, pages);
i18nClient(DICT, OUT + '/assets'); // ブラウザ用辞書 i18n.ja.js などを出力
i18nReport(); // 未翻訳の文を i18n-missing.json に書き出す
ライブラリはバンドラーを通さず、node_modules から dist/ にそのままコピーしています。
fs.copyFileSync('node_modules/pdf-lib/dist/pdf-lib.min.js', VEN + '/pdf-lib.min.js');
for (const f of ['pdf.min', 'pdf.worker.min'])
fs.copyFileSync(`node_modules/pdfjs-dist/legacy/build/${f}.mjs`, `${VEN}/pdfjs/${f}.js`);
多言語化: 英語の文そのものをキーにする
URL
- 韓国語がデフォルトで
/ - それ以外は
/en//ja//zh//es/の下に同じパスで生成 - 全ページに
hreflang(とx-default)を入れ、sitemap.xmlにもxhtml:linkで各言語版を書きます
例外として、給与計算のサイトは韓国の税制専用なので韓国語のみです。そのため「ほぼ全部5言語」です。
コード側: 韓国語と英語を並べて書く
ページのコードでは、韓国語と英語を並べて書きます。
T('다른 PDF 도구', 'More PDF tools')
日本語・中国語・スペイン語は、この英語の文をキーにして別ファイルから引きます。キーを別に考えなくていいのが利点です。
i18n.txt: 4行で1エントリ
@Merge PDF
PDF結合
合并 PDF
Unir PDF
@英語 の次に日本語、中国語、スペイン語が1行ずつ続きます。改行を含む文は \n と書きます。読み込みはこれだけです。
export function loadDict(file = 'i18n.txt') {
let src;
try { src = fs.readFileSync(file, 'utf8'); } catch { return {}; }
const lines = src.replace(/\r/g, '').split('\n'), d = {}, dec = (x) => (x || '').replace(/\\n/g, '\n');
for (let i = 0; i < lines.length; i++) if (lines[i].startsWith('@')) {
d[dec(lines[i].slice(1))] = [dec(lines[i + 1]), dec(lines[i + 2]), dec(lines[i + 3])];
i += 3;
}
return d;
}
長い本文(説明文やFAQ)は同じ形式の i18n-long.txt に分けています。
訳がない文はビルド時にファイルへ出す
const MISSING = new Map();
export function i18n(dict) {
const IDX = { ja: 0, zh: 1, es: 2 };
return (L) => {
const t = (ko, en) => {
if (L === 'ko') return ko;
if (L === 'en') return en;
const row = dict[en];
if (row && row[IDX[L]] != null && row[IDX[L]] !== '') return row[IDX[L]];
MISSING.set(en, true); // 訳がない
return en; // とりあえず英語で出す
};
return { t /* ほか */ };
};
}
訳がない文は英語のまま出力し、ビルドの最後に i18n-missing.json へ書き出します。訳が揃っていればこのファイルは消えます。英語の原文を少し直すと、その文の訳が「未翻訳」としてここに出てくるので、直し漏れに気づけます。
ブラウザ側も同じキーで引く
ボタンの文言など、JavaScriptで出す短い文は、ビルド時に言語別の辞書 i18n.ja.js などを書き出しておき(400文字未満の文だけ)、ページで読み込みます。
// core/common.js
const D = window.I18N || {};
U.t = (ko, en) => (U.lang === 'ko' ? ko : U.lang === 'en' ? en : D[en] != null ? D[en] : en);
韓国語と英語のページでは辞書ファイルを読み込みません。
日本語ならではの調整
日本語UIでは、パズルの名前を「数独」ではなく「ナンプレ」と表記しています。
処理はすべてブラウザの中
- PDF: pdf-lib と pdf.js
- 画像: canvas
- 設定や結果: localStorage(
try/catchで包んだ小さなラッパー経由)
ファイルをサーバーへ送らないので、アップロード先のサーバーも保存先のDBもいりません。
OG画像もビルド時に作る
OG画像(1200×630)は、SVGを組み立てて @resvg/resvg-js でPNGにしています。フォントは Pretendard です。外部の画像生成サービスは使っていません。
デプロイ
Cloudflare Pages に、サイトごとのプロジェクト(tooleek-<フォルダ名>)としてアップロードしています。シェルスクリプト1本で、各サイトのビルドとアップロードを順番に行います。アップロード後は、本番のHTMLとローカルのビルド結果を比べるスクリプトで確認しています。
まだできていないこと
- 日本語・中国語・スペイン語の訳は、まだネイティブのチェックを受けていません。不自然なところがあれば教えてください。
- 反応速度・タイピングなどのテストでは、他の利用者と比べる順位やパーセンタイルを、根拠になるデータがないのであえて入れていません。
※この記事はAI(Claude)の支援を受けて書き、内容は実際のリポジトリのコードと照らし合わせて確認しました。