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?

Tailwind クラスを CSS に変換するツールをブラウザで実装 — ユーティリティ名は実は CSS のどこへ落ちるのか

0
Posted at

Tailwind は「ただの CSS」と言われる。では bg-blue-500 text-white px-6 py-3 rounded-lg font-bold shadow-md実際にどの CSS に変換されているのか? Tailwind クラス文字列を貼ると素の CSS に変換 + live preview する 500 行 vanilla JS ツールを書いた。実装してみると、ドキュメントを読むより Tailwind の設計が腹落ちした。

🌐 Demo: https://sen.ltd/portfolio/tw-to-css/
📦 GitHub: https://github.com/sen-ltd/tw-to-css

Screenshot

なぜ自作するか

理由 2 つ:

  1. Tailwind チュートリアルの大半は「変換後の CSS」を見せない。読者はクラス名のパターンマッチで覚えていて、各クラスが何を する のかは曖昧。input/output を並べて見せると map が可視化される。
  2. 自分で実装すると Tailwind の設計が最速で頭に入るbg-{color}-{shade} を 1 ハンドラとして書いた瞬間、全ユーティリティが同じ shape の繰り返しであることが見える。

このツールは Tailwind の再実装ではない。デフォルトテーマのうちチュートリアルや quickstart で使われる ~100 ユーティリティに絞っている。目的は「マッピングを説明する」こと。

アーキテクチャ

tailwind-data.js  ← ルックアップテーブル: カラーパレット、スペーシング、フォントサイズ、…
parser.js         ← トークナイザ + ハンドラ配列(DOM 非依存、Node でテスト可)
app.js            ← UI グルー: 入力 → parser → live preview + CSS 出力

parser.jsparse(input){ rules, unrecognised } を返す。パイプライン全体が pure なので、node --test で 43 件のケースをブラウザなしで走らせられる。

ハンドラ配列 — ユーティリティ追加 = 関数追加

全ユーティリティが HANDLERS 配列の 1 エントリ。パーサは順に試して、最初に non-null を返したハンドラの結果を採用する。

const HANDLERS = [
  // 完全一致ユーティリティ
  layoutHandler("flex",     [["display", "flex"]]),
  layoutHandler("flex-col", [["flex-direction", "column"]]),
  layoutHandler("hidden",   [["display", "none"]]),

  // プレフィックス + コールバック
  prefixHandler("bg-", (v) => {
    const hex = resolveColor(v);
    return hex ? [["background-color", hex]] : null;
  }),
  prefixHandler("text-", (v) => {
    if (v in FONT_SIZE) { const [s, l] = FONT_SIZE[v]; return [["font-size", s], ["line-height", l]]; }
    if (["left","center","right","justify"].includes(v)) return [["text-align", v]];
    const hex = resolveColor(v);
    return hex ? [["color", hex]] : null;
  }),

  // スペーシング系はヘルパで共通化
  spacingHandler("p",  "padding"),
  spacingHandler("px", ["padding-left", "padding-right"]),
  spacingHandler("py", ["padding-top",  "padding-bottom"]),
  spacingHandler("w",  "width"),
  spacingHandler("h",  "height"),
  // ...
];

この形が Tailwind が「一貫している」と感じる正体: ユーティリティ名 = プレフィックス + スケールキー、しかも同じ 0, 0.5, 1, 2, 4, 8, … のスケールが至る所で使われている。自分で書くとこの構造が体に染み込む。

クラス名のオーバーロード問題(text- 系)

text- は Tailwind で最もオーバーロードされたプレフィックス:

クラス 設定するもの
text-sm font-size + line-height
text-center text-align
text-blue-500 color

ナイーブなパーサは 1 つだけ拾って他を壊す。実際のハンドラは 順序付きディスパッチ:

prefixHandler("text-", (v) => {
  // 1. フォントサイズスケール (sm, md, lg, xl, 2xl, ...)
  if (v in FONT_SIZE) {
    const [size, lh] = FONT_SIZE[v];
    return [["font-size", size], ["line-height", lh]];
  }
  // 2. テキスト alignment キーワード
  if (["left","center","right","justify"].includes(v)) {
    return [["text-align", v]];
  }
  // 3. カラー解決にフォールスルー
  const hex = resolveColor(v);
  return hex ? [["color", hex]] : null;
});

順序は適当ではない — Tailwind 自身がほぼこの順で解釈するので、text-sm は仮想的に "sm" という名前のカラーがあっても勝つし、text-blue-500blue-500FONT_SIZE に無いので fall-through してカラーに落ちる。同じパターンが border- (カラー vs 太さ) ほか少数のクラスにも適用される。

スペーシングスケールは n * 0.25rem だけ

const SPACING_BASE = [
  0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8, 9, 10, 11, 12,
  14, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 72, 80, 96,
];
for (const n of SPACING_BASE) {
  SPACING_MAP[String(n)] = n === 0 ? "0px" : `${n * 0.25}rem`;
}
SPACING_MAP["px"] = "1px";
SPACING_MAP["auto"] = "auto";
SPACING_MAP["full"] = "100%";

これがスペーシングシステムの全て。p-4padding: 1remmt-8margin-top: 2remgap-2gap: 0.5rem。Tailwind を使ってきて染みついた「8px グリッド感覚」の正体は 0.25rem × 2 = 0.5rem ≈ 8px (デフォルトの root font 16px 換算)。それだけ。

方向ショートカット (px, py, mx, my) は 1 行の一般化:

function spacingHandler(prefix, cssProp) {
  return (cls) => {
    if (!cls.startsWith(`${prefix}-`)) return null;
    const v = cls.slice(prefix.length + 1);
    if (!(v in SPACING_MAP)) return null;
    const props = Array.isArray(cssProp) ? cssProp : [cssProp];
    return props.map((p) => [p, SPACING_MAP[v]]);
  };
}

// usage:
spacingHandler("px", ["padding-left", "padding-right"]);

「同じプロパティに対しては最後に書いたクラスが勝つ」

parse("bg-red-500 bg-blue-500")
// → { rules: [
//     { class: "bg-red-500",  declarations: [["background-color", "#ef4444"]] },
//     { class: "bg-blue-500", declarations: [["background-color", "#3b82f6"]] },
//   ] }

両方の rule がパースを生き残るが、toCSS() が単一ブロックに畳んで後勝ち:

export function toCSS(rules, selector = ".preview") {
  const seen = new Map();
  for (const r of rules) {
    for (const [prop, value] of r.declarations) {
      seen.set(prop, value);  // 同じプロパティの先行 value を上書き
    }
  }
  // ...
}

Tailwind の「同じプロパティに対しては最後に書いたクラスが勝つ」直感と一致する。本物の Tailwind は CSS の source ordering + JIT コンパイルでこれを実現しているが、静的なクラスリストに対しては Map.set を順に呼ぶだけで等価。

(本物の Tailwind には @layer で component と utility の優先順位を制御する仕組みがあるが、1 つのクラスリスト内ではこのルールに収束する。)

見えない部分をテストする

parser.js が pure なので各ユーティリティを単体テストできる:

test("text-blue-500", () => {
  assert.deepEqual(classToDeclarations("text-blue-500"),
    [["color", "#3b82f6"]]);
});

test("h-screen → 100vh", () => {
  assert.deepEqual(classToDeclarations("h-screen"),
    [["height", "100vh"]]);
});

test("w-screen → 100vw, NOT 100vh", () => {
  assert.deepEqual(classToDeclarations("w-screen"),
    [["width", "100vw"]]);
});

test("不正クラスは null を返す", () => {
  assert.equal(classToDeclarations("not-a-real-class"), null);
});

43 件のテストでトークナイズ、各ユーティリティカテゴリ、text- の順序付きディスパッチ、マッチしないケース、CSS レンダリングを網羅。w-screen vs h-screen のテストは実バグの guard で、実装初期に両方とも 100vh にしていた(width には間違い)。

実装していないもの

  • バリアント (hover:, md:, dark:) — メディアクエリ / 擬似クラスラッパが必要。「名前 → CSS 値」デモンストレータの趣旨を外れる。
  • 任意値 (p-[17px], bg-[#abcdef]) — 追加自体は容易だが、目的は標準テーマであって Tailwind 再実装ではない。
  • プラグイン / カスタムテーマ — 同上。

仮にこれらを足したくなったら、ハンドラ配列の形のまま素直に追加できる(バリアントは既存ハンドラのラッパとして書ける)。

まとめ

  • Tailwind の大部分は デフォルトテーマの 50 行ぐらいのルックアップテーブル — カラーパレット × shade、スペーシングスケール、フォントサイズ、weight。残りは命名規約。
  • bg-{color}-{shade} はプレフィックス + 2 次元テーブルのキー。自分で実装すると設計空間が一目で見える。
  • text- は意図でオーバーロードされている(size vs align vs color)。順序付きディスパッチ(size → align → color)が綺麗に解決する。
  • 「最後のクラスが勝つ」 はパース済み rules を Map.set で順に処理すれば 1 つのクラスリスト内では等価。
  • パーサを DOM 非依存に保つと node --test で全ユーティリティの分岐をカバーできる。

リポジトリ: https://github.com/sen-ltd/tw-to-css

このツールは弊社の OSS ポートフォリオ #241 として作成しました。SEN 合同会社(東京)では小さくて切れ味のあるツール群を継続的に公開しています: https://sen.ltd/portfolio/

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?