0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

個人開発で152個のブラウザ完結型ツールサイトを作った話(Astro + Vue3 + TypeScript)

0
Posted at

はじめに

Torinoa Tools という、152個の開発者向けツールを1つのサイトにまとめた個人開発プロジェクトを作っています。Base64変換、JSON整形、正規表現テスター、サブネット計算、AES暗号化/復号など、日々の開発で「ちょっと使いたい」ツールを、すべてブラウザ内で完結する形で実装しています。

  • サーバー処理なし: 入力データは外部に送信されない(プライバシー面でのメリット)
  • 日英バイリンガル: Content Collectionsでi18nを構築
  • 静的サイト: Astro + Vue3 + TypeScriptで構築、Docker配布にも対応

この記事では、マーケティング的な機能紹介ではなく、実装上どう作っているかを中心に、いくつかの技術的なトピックを掘り下げます。

全体構成

Astro (SSG)
 └─ 各ツールごとに独立した .astro ページ(自動生成)
     └─ Vue 3 コンポーネント(client:load)が実際のロジックを担当

ツール数が増えるにつれて顕在化した問題と、その解決策から紹介します。

CSS 755KB問題と、その解決アーキテクチャ

以前は pages/tools/[slug].astro という1つの動的ルートを143個のツールが共有し、どのツールを表示するかは ToolWidget.astro という「スイッチボード」コンポーネントが143個のVueウィジェットを全て静的importして分岐する形になっていました。

これが原因で、Viteが「この1つのルートファイルの依存グラフ全体から届く可能性のあるCSS」を1つのチャンクにまとめてしまい、実測755KBのCSSが全286ページ(日英×143)に配信されるという問題が起きました。実際に使うのは1ツール分のCSSだけなのに、です。

原因は「ファイルの分け方」ではなく、「複数のツールが1つのルートファイルを共有していること自体」でした。そこで、ツールごとに完全に独立したページファイル(=Viteにとって別モジュール)を、ビルド前に機械的に生成するスクリプトを書きました。

// scripts/generate-tool-pages.mjs(抜粋)
function renderJaPage({ slug, component, importPath }) {
  const shellImport = shellImportPath(JA_OUT_DIR);
  const widgetImport = resolveWidgetImportPath(importPath, JA_OUT_DIR);
  return `---
import { getCollection } from "astro:content";
import ToolPageShell from "${shellImport}";
import ${component} from "${widgetImport}";

const entries = await getCollection(
  "tools",
  ({ id }) => id.split("/").pop()?.replace(/\\.md$/, "") === "${slug}",
);
const entry = entries[0];
---

<ToolPageShell entry={entry} locale="ja">
  <${component} locale="ja" client:load />
</ToolPageShell>
`;
}

src/data/tool-widgets.jsonslug → { componentName, importPath } のマッピングを持たせ、これを唯一の情報源として、日英それぞれの .astro ファイルをビルド前(predev / prebuild)に自動生成しています。生成物はGit管理せず、共通レイアウトは ToolPageShell.astro に切り出して重複を避けています。

結果、CSSチャンクは755KB→ページごとに16KB未満まで削減できました。新しいツールを追加する際も、tool-widgets.json にエントリを1行足すだけで、ページ生成・CSS分割・i18nの整合性チェックまで自動で回る仕組みになっています。

Web Crypto APIだけで作ったAES暗号化ツール

外部の暗号ライブラリに依存せず、ブラウザ標準の SubtleCrypto だけでAES-GCM / AES-CBCの暗号化・復号を実装しています。パスフレーズから鍵を導出する際はPBKDF2(SHA-256、10万回反復)を使い、salt・IV・アルゴリズム情報を暗号文と一緒に1つのBase64文字列にまとめる自己完結型フォーマットにしています。

const PBKDF2_ITERATIONS = 100_000;

async function deriveKey(
  passphrase: string,
  salt: Uint8Array,
  algorithm: AesAlgorithm,
  keySize: AesKeySize,
): Promise<CryptoKey> {
  const enc = new TextEncoder();
  const baseKey = await crypto.subtle.importKey(
    "raw",
    enc.encode(passphrase),
    "PBKDF2",
    false,
    ["deriveKey"],
  );
  return crypto.subtle.deriveKey(
    { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
    baseKey,
    { name: algorithm, length: keySize },
    false,
    ["encrypt", "decrypt"],
  );
}

復号時にパスフレーズ以外の入力が不要になるよう、[アルゴリズム種別, 鍵長] + salt + IV + 暗号文 を1本のバイト列にまとめてBase64化しています。AES-GCMは認証タグの検証を兼ねるため、パスフレーズ違いや改ざんがあれば crypto.subtle.decrypt が例外を投げるだけで、改ざん検知を別途実装する必要がありません。ただしこの結合フォーマットは自己完結が目的で、OpenSSL等の外部ツールとのバイナリ互換は意図していません。

サブネット計算ツールのVLSMパッキング

ネットワークエンジニアリング寄りのツールとして、CIDRの分割(VLSM: Variable Length Subnet Masking)も実装しています。要求ホスト数のリストを大きい順にソートし、必要なホストビット数を ceilLog2(hosts + 2) で求めた上で、サブネット境界にアラインしながら順に詰めていくシンプルな貪欲法です。

const sorted = [...reqs].sort((a, b) => b - a); // 降順ソートで効率よく詰める

for (const hosts of sorted) {
  const hostBits = ceilLog2(hosts + 2); // ネットワークアドレス+ブロードキャストの2つを引く
  const subnetSize = Math.pow(2, hostBits);
  const alignedNum = (Math.ceil(currentNum / subnetSize) * subnetSize) >>> 0;

  if (alignedNum + subnetSize - 1 > networkNum + parentTotal - 1) {
    // 親ネットワークに収まらない場合はエラー
  }
  // ... サブネット情報を積み上げ、currentNum を進める
}

親ネットワークのアドレス空間を超えたら明示的にエラーを返すようにしているので、「必要ホスト数を全部詰め込めるか」をその場で確認できます。

localStorageの自動保存と「リストアバナー」

ほぼ全てのツールに、入力値をタブを閉じても復元できる仕組み(useLocalStorage Composable)を入れています。

export function useLocalStorage<T>(
  key: string,
  defaultValue: T,
  options: { debounceMs?: number; syncAcrossTabs?: boolean } = {},
): Ref<T> {
  const storageKey = `torinoa:${key}`;
  const state = ref<T>(defaultValue) as Ref<T>;

  if (typeof window !== "undefined") {
    const raw = window.localStorage.getItem(storageKey);
    if (raw !== null) state.value = JSON.parse(raw) as T;
  }
  // ... デバウンス付きで書き込み
  return state;
}

Astroはビルド時にSSRするため window が存在しないタイミングがあり、その場合はデフォルト値のみを保持する通常のrefとして振る舞うようガードしています。

なお、パスワード生成ツールやJWTツール、Cookieパーサー、HMAC生成ツールなど機密性の高い入力を扱うツールでは、この自動保存・復元自体を無効化しています。地味ですが、開発中に「復元ロジックは実装したのに、対応するUI要素(.restore-banner)をテンプレートに置き忘れていて、機能はあるのにユーザーからは見えない」というバグを何度も踏んだので、今はこのパターン自体を「実装済みだが本当にUIに出ているか」を毎回チェックする項目にしています。

日英バイリンガル運用と整合性チェック

Content Collectionsで src/content/tools/(日本語)と src/content/tools-en/(英語)を分けて管理し、scripts/check-i18n-en-parity.mjs で以下を毎回強制的にチェックしています。

  • ja/enのMarkdownファイル数が一致しているか
  • tools.json / tools.ts のslug数が一致しているか
  • カテゴリ数が一致しているか

新しいツールを1つ追加するたびに、tools.jsontools.ts・ja/en Markdown の4箇所を必ず揃える必要があり、このスクリプトがビルド前 (prebuild) に自動実行されて、抜け漏れがあればビルド自体を止めます。

Dockerでのセルフホスティング

ghcr.io/mi8bi/torinoa-tools としてビルド済みイメージをGHCRに公開しており、GitHub Actions CIで latest / semver / SHA の3種のタグを自動プッシュしています。静的サイトなので、社内ネットワークなど外部に出せない環境でもそのままセルフホストできます。

おわりに

152個のツールを個人でメンテナンスする上で一番効いているのは、「新しいツールを1つ追加する作業を、なるべく機械的なチェックリストに落とし込む」ことでした。CSSチャンクの自動生成、i18nの整合性チェック、リストアバナーの実装漏れなど、どれも「人間が気をつける」より「スクリプトが強制する」方が確実だと実感しています。

サイトはこちらです: https://tools.torinoa.com

質問やフィードバックがあれば、コメントで教えてもらえると嬉しいです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?