
ts-morphを使ってtailwindのレスポンシブ対応
はじめに
最近弊社ではts-morphを使って様々なコードを生成し、開発の効率化を図っています。
今回はtailwindでのレスポンシブ対応をts-morphで自動生成する方法を解説します。
前置きが長いのでさっさと実装を知りたい人は 実装 まで飛んでください
方針決め
次の手順で設計・実装を行いました。
- 問題の洗い出し
 - 解決方法考案
 - 作業フロー決め
 - 実装の設計
 - 実装
 
この解説でも同様の手順を踏んで解説していきます。
問題の洗い出し
まず何が問題だったのかというと、次の要件がありました。
- 小さいサイズのスマホ版のデザインのみがある
 - 対応するのはスマホだけでいい(タブレット・PCはスコープ外)
 - 対応するスマホのアスペクト比・サイズは決まっていない
 
こうなってくると問題となるのが次のことです。
- 1つのデザインをあらゆるスマホのアスペクト比・サイズに対応させる
 
この問題に対する解決策を考えていきます。
解決方法考案
こういう時のよくある方法が次です。
- デザインのpxをvwに変換する
 
例えば「デザインが横幅350pxで作成されていて、余白が4pxなら余白を4 / 350 = 1.14vwとする」とすれば350pxの時の余白は当然4pxになります。
これをすべての大きさに対して適応すれば少なくともデザインと同じアスペクト比のスマホに対しては問題なく表示できます。
またデザインと違うアスペクト比に対しても、実はデザインがない割にはいい感じに表示されます。
そのため今回もこれを採用したいと思います。
作業フロー決め
Figmaがこの変換を行ってくれれば楽なのですが、そういったプラグインが見つかりませんでした。
なのでいろいろ考えた結果次の作業フローで変換処理を行うことにしました。
- pxで実装
 - ts-morphでvwに変換
 
実装の設計
作業フローが決まり、変換処理のインプットとアウトプットのイメージがつくようになったので設計をします。
設計といっても変換のフローを考えるだけです。
- jsx内のclassNameの指定を見つける
 - その中からすべての文字列を取得する
 - tailwindのarbitrary valuesでpxで指定してるクラスを見つける
 - vwに変換し置き換える
 - 変更の保存
 
実装
0. ts-morthの初期化処理
最初にts-morphを初期化します。
Projectインスタンスを作ってtsconfig.jsonを読み込みます。
これによってプロジェクト内のすべてのファイルがProjectインスタンスに読み込まれます。
※ projectPathは適宜置き換えてください。
import { Project } from "ts-morph";
const project = new Project({
  tsConfigFilePath: path.join(projectPath, "tsconfig.json"),
});
今回はプロジェクトのすべてのファイルに対して検索をかけるのでここでプロジェクトを読み込みますが、この処理は数秒かかるくらい重いので、必要がない場合はやらないほうがいいです。
tsConfigFilePathはオプショナルなので、下記のように値を渡さなければ読み込み処理が走りません。
import { Project } from "ts-morph";
const project = new Project({});
1. jsx内のclassNameの指定を見つける
まずプロジェクト内のコンポーネントをすべて走査します。
弊社ではすべてのReactコンポーネントをcomponentsフォルダ以下で管理してるので、components以下のすべてのファイルを走査します。
※ projectPathは適宜置き換えてください。
  project
    .getDirectory(path.join(projectPath, "components"))
    ?.getDescendantSourceFiles()
    .forEach((sourceFile) => {
      sourceFile.forEachDescendant((node) => {
        if (
          node.isKind(SyntaxKind.JsxAttribute) &&
          node.getNameNode().getText() == "className"
        ) {
        ...
      });
    });
getDescendantSourceFilesですべてのファイルを取得できるので、sourceFile.forEachDescendantで各ファイルのastを走査します。
astを走査する際に、jsx内のclassNameの指定を見つけます。
 if (           node.isKind(SyntaxKind.JsxAttribute) &&           node.getNameNode().getText() == "className"         ) {がその箇所になります。
このjsx内のclassNameの指定に対して次の処理を走らせます。
2. その中からすべての文字列を取得する
classNameの指定を見つけたら、その右辺を取得しさらに走査します。
右辺の取得にはgetInitializerOrThrowを使います。
const initializer = node.getInitializerOrThrow();
initializer.forEachDescendant((node) => {
  if (node.isKind(SyntaxKind.StringLiteral)) {
    replaceClassName(initializer, 350);
  }
});
if (initializer.isKind(SyntaxKind.StringLiteral)) {
  replaceClassName(initializer, 350);
}
右辺がclsxなどの関数だった場合さらにastを走査する必要があるので、initializer.forEachDescendantをして操作し、node.isKind(SyntaxKind.StringLiteral)で文字列リテラルを探してます。
関数じゃなく文字列だった場合の処理も加えます。
3. tailwindのarbitrary valuesでpxで指定してるクラスを見つける
replaceClassNameの中身の処理になります。
ここまでの処理で文字列リテラルが取れたので文字列リテラルを解析し、tailwindのarbitrary valuesでpxで指定してるクラスを見つけます。
解析には正規表現を使いました。
function replaceClassNamea(node: Node, width: number) {
  const text = node.getText();
  let newText = text;
  const reg = /([a-zA-Z:-]*)-\[([-0-9]*px)\]/g;
  let result = reg.exec(text);
  ...「4. vwに変換し置き換える」の処理が続く
}
4. vwに変換し置き換える
変換し、node.replaceWithTextで置き換えます。
while (result) {
  const [org, prop, px] = result;
  const num = parseInt(px);
  const vw = ((num / width) * 100).toFixed(2);
  const vwStr =`${prop}-[${vw}vw]`
  newText = newText.replace(
    new RegExp(`(?<![a-zA-Z-])${escapeRegExp(org)}`),
    vwStr,
  );
  result = reg.exec(text);
}
node.replaceWithText(newText);
new RegExp(`(?<![a-zA-Z-])${escapeRegExp(org)}`)では元のテキストをRegExpに変換できるようエスケープしてます。
エスケープする関数は次です。
適当なファイルに置き、importして下さい。
function escapeRegExp(string: string) {
    return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
5. 変更の保存
最後にここまで加えた変更をプロジェクトに反映させます。
await project.save();
これで完成です。
全体
ここまでの処理を1つにすると次のようになります。
※ projectPathは適宜置き換えてください。
import path from "path";
import { Node, Project, SyntaxKind } from "ts-morph";
import { escapeRegExp } from "./utils/escapeRegExp";
async function main(
) {
  const project = new Project({
    tsConfigFilePath: path.join(projectPath, "tsconfig.json"),
  });
  project
    .getDirectory(path.join(projectPath, "components"))
    ?.getDescendantSourceFiles()
    .forEach((sourceFile) => {
      sourceFile.forEachDescendant((node) => {
        if (
          node.isKind(SyntaxKind.JsxAttribute) &&
          node.getNameNode().getText() == "className"
        ) {
          const initializer = node.getInitializerOrThrow();
          initializer.forEachDescendant((node) => {
            if (node.isKind(SyntaxKind.StringLiteral)) {
              replaceClassName(initializer, 350);
            }
          });
          if (initializer.isKind(SyntaxKind.StringLiteral)) {
            replaceClassName(initializer, 350);
          }
        }
      });
    });
  await project.save();
}
function replaceClassName(node: Node, width: number) {
  const text = node.getText();
  let newText = text;
  const reg = /([a-zA-Z:-]*)-\[([-0-9]*px)\]/g;
  let result = reg.exec(text);
  while (result) {
    const [org, prop, px] = result;
    const num = parseInt(px);
    const vw = ((num / width) * 100).toFixed(2);
    const vwStr =`${prop}-[${vw}vw]`
    newText = newText.replace(
      new RegExp(`(?<![a-zA-Z-])${escapeRegExp(org)}`),
      vwStr,
    );
    result = reg.exec(text);
  }
  node.replaceWithText(newText);
}
void main();
まとめ
60行に満たない少ないコードでスマホに対するレスポンシブ対応ができました。
ts-morph最強!!!











