Figma API × Lisp (Clojure) で作るレスポンシブスタイルのエクスポーター
はじめに
本記事では、FigmaのテキストスタイルをFigma APIから取得して、実際に使いやすい形に整形する方法を紹介します。
「そんなのはFigma MCPやFigma SitesみたいなAIツールでサクッと終わらせるよ!」という方は、ここでそっと閉じてください。とはいえ、本記事ではFigmaはあくまで入り口として、Lispの活用ポイントやTypeScriptなど静的型付け言語との違いについても触れています。プログラミング言語や設計の視点に興味がある方に読んでいただけると嬉しいです。
本記事で扱うコードはGitHubで公開していますので、興味のある方はぜひご覧ください。
レスポンシブデザインとFigmaスタイルの課題
Webサイトのレスポンシブデザインを作成する際、複数のデバイスサイズに対応したテキストスタイルの管理は大きな課題です。例えば、同じ「heading」というスタイルでも、デスクトップでは32pxで行間150%、スマートフォンでは24pxで行間140%といった具合に、ブレイクポイントごとに調整が必要です。こうした調整は、Figmaのスタイルパネルでスマートフォン、タブレット、デスクトップなど異なる画面サイズに対応するテキストスタイルをレイヤー構造(フォルダ階層)で整理することで管理しやすくなります。
これらの情報をデザインから開発へ正確に伝えるためには、Figmaで定義されたスタイルを開発環境で使える形式に変換する必要があります。特に、複数のブレイクポイントを考慮したCSSメディアクエリや、レスポンシブ設計のためのデータ構造への変換が求められます。この変換処理では、スタイル名の命名規則やデータの構造化、バリデーションなど、複雑なデータ操作が必要となります。
本記事では、この課題に対してClojureを用いた効率的な解決方法を示すとともに、TypeScriptでのアプローチと比較しながら、関数型プログラミングの強みを具体的に解説していきます。
Figma APIとデータ構造の調整
Figma APIでは、スタイル情報がレイヤー構造を反映した階層形式で返されます。例えばFigmaでのテキストスタイルの階層構造が以下のようになっていたとします。
heading
└─ ja
├─ sm
├─ md
├─ lg
└─ xl
└─ en
├─ sm
├─ md
├─ lg
└─ xl
body
└─ ja
├─ sm
├─ md
├─ lg
└─ xl
└─ en
├─ sm
├─ md
├─ lg
└─ xl
これがFigma API経由で取得すると、パスとして「/」で区切られた形式で表現されます。
{
"heading/ja/sm": {
"fontSize": 15.0,
"fontWeight": 400,
"fontFamily": "Noto Sans JP",
"letterSpacing": 0.5,
"lineHeight": "150%"
},
"heading/ja/md": {
"fontSize": 18.0,
"fontWeight": 400,
"fontFamily": "Noto Sans JP",
"letterSpacing": 0.25,
"lineHeight": "145%"
},
"body/en/lg": {
"fontSize": 16.0,
"fontWeight": 300,
"fontFamily": "Roboto",
"letterSpacing": 0,
"lineHeight": "140%"
}
// ...他のスタイルも同様の形式で続きます
}
この構造は「/」で区切られており、スタイル名(heading, body)、言語(ja, en)、ブレイクポイント(sm, md, lg, xl)という階層を表しています。
開発側では、Chakra UIのようなUIフレームワークを使う場合、理想的には以下のようなブレイクポイントが最下層になっている形式が望ましいです。
// Chakra UIで扱いやすい形式
const textStyles = {
heading: {
ja: {
fontSize: { sm: "15px", md: "18px", lg: "24px", xl: "32px" },
fontWeight: { sm: 400, md: 400, lg: 500, xl: 600 },
fontFamily: { sm: "Noto Sans JP", md: "Noto Sans JP", lg: "Noto Serif JP", xl: "Noto Serif JP" },
letterSpacing: { sm: "0.5px", md: "0.25px", lg: "0px", xl: "0px" },
lineHeight: { sm: "150%", md: "145%", lg: "140%", xl: "130%" }
},
en: {
// 英語向けのスタイル
}
},
body: {
// 本文スタイル
}
};
しかし、デザイナーにとっては必ずしもこのような階層構造が使いやすいとは限りません。言語を最上位に置きたい場合もあると思います。
ja
└─ heading
├─ sm
├─ md
├─ lg
└─ xl
└─ body
├─ sm
├─ md
├─ lg
└─ xl
en
└─ heading
├─ sm
├─ md
├─ lg
└─ xl
└─ body
├─ sm
├─ md
├─ lg
└─ xl
デザインの文脈では、デザイナー自身が最も作業しやすい方法でスタイルを整理すべきです。そのため、プログラム側でこの階層構造を柔軟に解釈し、必要な形式に変換する仕組みが必要になります。
この変換処理は、Lisp(Clojure)のようにデータ駆動型アプローチを得意とする柔軟な言語が特に力を発揮する部分です。Lisp系言語は本質的に、データやコードを同じ構文木(AST)として扱う仕組みを持つため、構文解析や変換パターンの拡張が自然に書ける柔軟性があります。今回は主にデータ構造の変換でその強みを活かしています。以降で、Lispの方言であるClojureを使って、この変換処理をどのように実装しているかを見ていきます。
データ変換の課題とLispの選択
当初、このFigmaスタイル変換ツールはTypeScriptで実装する予定でした。型安全性という大きなメリットは享受できるものの、実際に実装を進めていく中で、複雑なスタイル構造への対応や柔軟な変換処理に関していくつかの壁に直面することになりました。
Figmaから返されるスタイル名は多様なパターンを持ちますが、これは実際にはスタイルを作成するデザイナーの運用ルールや慣習によるところが大きく、そのため階層構造も複雑になりがちです。これらをTypeScriptで型定義すると、ユニオン型や型ガードが多用され、コードの複雑化が避けられません。また、新しい命名パターンが追加されると、型定義の見直しや追加作業が発生しやすくなります。
さらに、スタイル変換のロジックも、静的型システムの制約のために冗長なコードになりがちでした。すべての中間状態に対する型を定義し、型の整合性を保ちながら変換処理を書くのは予想以上に困難です。
もちろん、TypeScriptでも実装は可能かもしれません。var
を使ったミュータブルな変数の利用や、any
やunknown
で型チェックを回避し、as
演算子による型の矯正など、そうした妥協を許容してfor
文で強引に処理をぶん回すという手段も取れます。しかし、そのようなアプローチを取った時点で、「型安全性」というメリットを損なってしまい、TypeScriptを使うメリット自体がほとんど失われてしまいます。
※TypeScriptを正しく使えば型安全性や補完などの恩恵は大きいですが、複雑な階層データを扱う場合は型定義や変換ロジックが煩雑になりやすく、Lispのようなシンプルさや柔軟さとは対照的です。
そこで、このような柔軟なデータ変換に強いLisp、ここではClojureでの実装を検討することにしました。Lispの特徴である「コードとデータの境界の薄さ」と「データ変換の容易さ」が、この問題に適しているのではと考えたためです。
Clojureによるデータ変換の実装
ここでは、Clojureを使ったFigmaスタイルの変換処理の具体的な実装を見ていきます。transform.clj
に実装された主要な関数を通して、Clojureのデータ処理の強みを解説します。
1. スタイルの変換とフィルタリング
まず、Figma APIから取得した生のスタイルデータをCSS互換の形式に変換する関数を見てみましょう。
(defn convert-css-props
"Converts Figma style map to CSS-compatible format.
Calculates lineHeight from lineHeightPx and fontSize in %.1f%% format."
[style-props]
(let [px (get style-props "lineHeightPx")
fs (get style-props "fontSize")
lh (when (and px fs (not= fs 0))
(format "%.1f%%" (* (/ px fs) 100)))]
(when (and fs
(contains? style-props "fontWeight")
(or (get style-props "fontPostScriptName")
(get style-props "fontFamily"))
(contains? style-props "letterSpacing")
lh)
{:fontSize fs,
:fontWeight (get style-props "fontWeight"),
:fontFamily (or (get style-props "fontPostScriptName")
(get style-props "fontFamily")),
:letterSpacing (get style-props "letterSpacing"),
:lineHeight lh})))
この関数では、let
を使って中間変数を束縛し、条件に合ったデータのみを変換しています。ポイントは以下の通りです。
- 必要なプロパティの存在確認と計算を一箇所で行っている
-
when
を使って条件を満たさない場合はnil
を返し、後続の処理でフィルタリングできるようにしている - キー名をJavaScriptスタイル("fontWeight")からClojureスタイル(:fontWeight)に変換している
次に、複数のスタイルを一括で変換する関数を見てみましょう。
(defn map->css-props
"Batch converts multiple Figma styles to CSS format.
Filters out nil results from conversion."
[flat-styles]
(into {}
(keep (fn [[style-name props]]
(when-let [css-props (convert-css-props props)]
[style-name css-props])))
flat-styles))
この関数では、keep
という関数を使って変換しながらフィルタリングを行っています。nil
を返すエントリは除外され、最終的に新しいマップが生成されます。この実装の特徴は、
- わずか3行で複雑なデータ変換処理をシンプルに実現している
- 変換処理とフィルタリングを一度に行っている
TypeScriptで同様の処理を実装すると、例えば以下のようになるでしょう。
interface FigmaStyleProps {
fontSize?: number;
fontWeight?: number;
fontFamily?: string;
fontPostScriptName?: string;
letterSpacing?: number;
lineHeightPx?: number;
// 他にも多数のプロパティが存在
}
interface CssProps {
fontSize: number;
fontWeight: number;
fontFamily: string;
letterSpacing: number;
lineHeight: string;
}
function convertCssProps(styleProps: FigmaStyleProps): CssProps | null {
const px = styleProps.lineHeightPx;
const fs = styleProps.fontSize;
if (!fs || !px || fs === 0) return null;
const lh = `${((px / fs) * 100).toFixed(1)}%`;
if (!styleProps.fontWeight ||
(!styleProps.fontPostScriptName && !styleProps.fontFamily) ||
styleProps.letterSpacing === undefined) {
return null;
}
return {
fontSize: fs,
fontWeight: styleProps.fontWeight,
fontFamily: styleProps.fontPostScriptName || styleProps.fontFamily || "",
letterSpacing: styleProps.letterSpacing,
lineHeight: lh
};
}
function mapToCssProps(flatStyles: Record<string, FigmaStyleProps>): Record<string, CssProps> {
const result: Record<string, CssProps> = {};
Object.entries(flatStyles).forEach(([styleName, props]) => {
const cssProps = convertCssProps(props);
if (cssProps) {
result[styleName] = cssProps;
}
});
return result;
}
TypeScriptでは、型定義や型チェックの記述量が増えがちで、入れ子の条件分岐やマップ操作もやや煩雑になりやすい傾向があります。
2. スタイル名のパターン解析
次に、スタイル名から階層構造の部分(スタイルのベース名、言語、ブレイクポイント)を抽出する関数を見てみましょう。
(defn extract-parts
"Extracts breakpoint, language, and name parts from a style name.
Position of parts in the style name doesn't matter (order-independent)."
[style-name breakpoints languages require-breakpoint require-language]
(let [parts (str/split style-name #"/")
classified
(map (fn [part]
(cond
(and require-breakpoint (breakpoints part))
{:type :breakpoint, :value part}
(and require-language (languages part))
{:type :language, :value part}
:else
{:type :base-name, :value part}))
parts)
breakpoint (some #(when (= (:type %) :breakpoint) (:value %)) classified)
language (some #(when (= (:type %) :language) (:value %)) classified)
base-names (map :value (filter #(= (:type %) :base-name) classified))
base-name (when (seq base-names)
(str/join "/" base-names))]
(cond-> {:base-name (or base-name "")}
breakpoint (assoc :breakpoint breakpoint)
language (assoc :language language))))
この関数の特筆すべき点は以下の通りです。
- スタイル名のパターンが変わっても(例:「heading/ja/sm」→「ja/heading/sm」)、同じロジックで処理できる柔軟性
-
map
、some
、filter
などの高階関数を使った宣言的なスタイルの実装 -
cond->
を使った条件付きのマップ構築
TypeScriptでこれを実装すると
interface StyleParts {
baseName: string;
breakpoint?: string;
language?: string;
}
function extractParts(
styleName: string,
breakpoints: Set<string>,
languages: Set<string>,
requireBreakpoint: boolean,
requireLanguage: boolean
): StyleParts {
const parts = styleName.split('/');
type ClassifiedPart =
| { type: 'breakpoint', value: string }
| { type: 'language', value: string }
| { type: 'baseName', value: string };
const classified: ClassifiedPart[] = parts.map(part => {
if (requireBreakpoint && breakpoints.has(part)) {
return { type: 'breakpoint', value: part };
} else if (requireLanguage && languages.has(part)) {
return { type: 'language', value: part };
} else {
return { type: 'baseName', value: part };
}
});
const breakpoint = classified.find(p => p.type === 'breakpoint')?.value;
const language = classified.find(p => p.type === 'language')?.value;
const baseNames = classified
.filter(p => p.type === 'baseName')
.map(p => p.value);
const baseName = baseNames.length > 0 ? baseNames.join('/') : '';
const result: StyleParts = { baseName };
if (breakpoint) result.breakpoint = breakpoint;
if (language) result.language = language;
return result;
}
TypeScriptでは型の定義やnullチェックが多くなり、条件付きのオブジェクト構築も冗長になっています。
3. 階層構造の再構築
最後に、抽出したパーツを使って、目的の階層構造に再構築する関数を見てみましょう。ここが、TypeScriptなど静的型付け言語と比べてClojureの強みが発揮される部分です。Clojureでは、柔軟かつ宣言的なスタイルで、データ構造の再構築や入れ子の処理を非常に簡潔に実装できます。
(defn structure-style-output
"Converts flat CSS style map to structured format based on style hierarchy."
[css-styles breakpoints languages require-breakpoint require-language]
(let [desired-order [:base-name :language :prop-key :breakpoint]]
(reduce
(fn [acc [style-name props]]
(if-not (map? props)
acc
(let [parts (extract-parts style-name breakpoints languages require-breakpoint require-language)
{:keys [base-name language breakpoint]} parts]
(if (and base-name
(or (not require-breakpoint) breakpoint)
(or (not require-language) language))
(reduce-kv
(fn [acc' k v]
(let [path-elements {:base-name base-name,
:language language,
:prop-key k,
:breakpoint (when (and require-breakpoint breakpoint)
(keyword breakpoint))}
path (vec (remove nil? (map #(path-elements %) desired-order)))]
(assoc-in acc' path v)))
acc
props)
acc))))
{}
css-styles)))
この関数の特徴は以下の通りです。
-
desired-order
で出力する階層構造のキーの順番を定義している点。これにより、出力の階層構造の順序をここで決めておくことができ、将来的に順番を変えたい場合にも柔軟に対応できます -
reduce
とreduce-kv
を使って、入れ子構造のマップを一度の走査で構築している -
assoc-in
を使って、深いパスへの値の挿入を簡潔に行っている - パス自体を動的に生成している(
path (vec (remove nil? (map #(path-elements %) desired-order)))
)
TypeScriptで同様の実装を試みると次のようになります。
function structureStyleOutput(
cssStyles: Record<string, CssProps>,
breakpoints: Set<string>,
languages: Set<string>,
requireBreakpoint: boolean,
requireLanguage: boolean
): Record<string, any> {
return Object.entries(cssStyles).reduce((acc, [styleName, props]) => {
if (typeof props !== 'object' || props === null) return acc;
const parts = extractParts(styleName, breakpoints, languages, requireBreakpoint, requireLanguage);
const { baseName, language, breakpoint } = parts;
if (!baseName ||
(requireBreakpoint && !breakpoint) ||
(requireLanguage && !language)) {
return acc;
}
return Object.entries(props).reduce((innerAcc, [propKey, propValue]) => {
// nullやundefinedを除外してパスを作成
const pathParts = [baseName, language, propKey].filter(Boolean);
// 元のオブジェクトを破壊しないようにディープコピー
const result = JSON.parse(JSON.stringify(innerAcc));
// ネストされたオブジェクトを辿りながら、パス上のオブジェクトを作成
let current = result;
for (let i = 0; i < pathParts.length - 1; i++) {
const part = pathParts[i] as string;
if (!current[part]) current[part] = {};
current = current[part];
}
// 最終キーに値をセット(ブレイクポイントがあれば更に階層化)
const lastPart = pathParts[pathParts.length - 1] as string;
if (requireBreakpoint && breakpoint) {
if (!current[lastPart]) current[lastPart] = {};
current[lastPart][breakpoint] = propValue;
} else {
current[lastPart] = propValue;
}
return result;
}, acc);
}, {});
}
TypeScriptでは、深いパスへのアクセスとオブジェクトの不変更新が冗長になり、as
による型アサーションやlet
を使った変数管理も必要になるため、ミスも生じやすくなります。また、ディープクローンを手動で行う必要があり、パフォーマンスやコードの明瞭さに影響を与えます。
他にも、関数型に近い書き方(再帰やreduceのネスト、イミュータブル操作のユーティリティ関数など)も試してみましたが、結局どれもClojureのようなシンプルさや直感的な書き方を実現できませんでした(単に私の実力不足による部分もあるかもしれませんが...)。
さらに、Clojureではdesired-order
のようなベクトルで出力階層の順番を簡単にコントロールできますが、TypeScriptではこのような柔軟な順序指定を実装しようとすると、ロジックがさらに煩雑になります。そのため、階層構造の順序変更への対応も難しいのが実情です。
このように、Clojureはデータ変換処理において、簡潔で宣言的なコードを書けるという大きな強みがあります。特に、複雑な階層構造を持つデータの変換においては、TypeScriptのような静的型付け言語と比較して、はるかに読みやすく保守しやすいコードになります。
その他の特筆すべき実装パターン
データ変換の他にも、Figmaスタイルエクスポーターには関数型プログラミングのパターンを活かした実装がいくつかあります。ここでは特にエラー処理とデータバリデーションについて解説します。なお、Figma APIとの通信部分については特別な工夫はしていないため割愛します。
モナディックなエラー処理
処理のパイプライン化とエラーの適切な伝播はデータ変換処理で重要な側面です。Clojureでは、Haskellのdo記法のようなモナディックなアプローチでエラーハンドリングを簡潔に実装できます。core.cljでは次のような>>=
関数を定義しています。
(defn >>=
"Monadic bind function. Executes next function if result is successful, propagates error otherwise."
[result f]
(if-let [value (:ok result)]
(f value)
result))
この関数は、成功した場合は次の処理に値を渡し、エラーがあれば自動的に伝播します。これによって、処理のパイプラインを構築する際に毎回エラーチェックを書く必要がなくなります。
(-> {:ok [token filekey breakpoints languages require-breakpoint require-language]}
;; 1. Fetch style information
(>>= (fn [[token filekey _ _ _ _]]
{:ok (assoc (styles/collect-text-styles token filekey)
:breakpoints breakpoints
:languages languages)}))
;; 2. Convert to CSS format
(>>= (fn [{:keys [ok breakpoints languages]}]
(let [flat-css-styles (transform/map->css-props ok)]
{:ok {:flat-css-styles flat-css-styles,
:breakpoints breakpoints,
:languages languages}})))
;; 以下、処理が続く...
handle-error)
このモナディックなアプローチの利点は
- 処理の流れが線形で読みやすい
- エラー処理ロジックが本来の変換処理から分離されている
- 個々の処理が純関数となり、テストが容易になる
- エラーが発生した場合、自動的に後続の処理をスキップする
関数型プログラミングでは、このようなエラー処理パターンがデータ変換パイプラインの構築に非常に適しています。EitherモナドやResultモナドと呼ばれるこのパターンは、関数型言語で広く使われています。TypeScriptでは、fp-tsライブラリが同様の機能を提供しており、関数型スタイルでエラーハンドリングを実装可能です。
宣言的なスキーマ検証
このツールでは、最終的には書き出されたJSONをTypeScriptで使用することを想定しています。異なる言語間でデータを受け渡す際、スキーマ検証は重要な役割を果たします。
Clojure側では、malliライブラリを使って簡潔なスキーマ検証を実装しています。
(def CssStyle
"Schema for validating individual CSS style objects."
[:map
[:fontSize double?]
[:fontWeight int?]
[:fontFamily string?]
[:letterSpacing double?]
[:lineHeight [:re #".+%"]]])
(def CssStyleMap
"Schema for validating the complete CSS style map."
[:map-of string? CssStyle])
(defn validate-css-style-map
"Validates a CSS style map using Malli schema."
[style-map]
(if (m/validate CssStyleMap style-map)
{:ok true}
{:error {:type :css-validation,
:details (me/humanize (m/explain CssStyleMap style-map))}}))
この検証は、API仕様の変更を早期に検知するための軽量なチェックとして機能しています。Figma APIからのデータを変換した後の最初の段階でスキーマ検証を行うことで、以降の処理でのエラーを防いでいます。
また、スタイル名のパターン一貫性の検証も実装しています。これは重要な役割を果たします。技術的にはFigmaのレイヤー構造に一貫性がなくても、Clojureの変換処理で強制的に統一された構造に変換することは可能です。しかし、Figmaのレイヤー構造に一貫性がない場合、それは多くの場合デザイナーの意図しないミスである可能性が高く、そのまま処理を進めるとデザインの意図が正しく反映されない恐れがあります。この検証は、そのようなミスを早期に発見するためのものです。
(defn validate-style-patterns
"Validates consistency of style name patterns."
[style-names breakpoints languages require-breakpoint require-language]
(let [classify (fn [part]
(cond
(and require-breakpoint (breakpoints part)) :breakpoint
(and require-language (languages part)) :language
:else :base-name))
patterns (map (fn [name]
(let [parts (str/split name #"/")]
(mapv classify parts)))
style-names)
unique-patterns (distinct patterns)]
(cond
;; More than one pattern (inconsistent)
(> (count unique-patterns) 1)
{:error {:type :inconsistent-patterns,
:details {:patterns unique-patterns,
:pattern_mismatches (map vector style-names patterns)}}}
;; Passes all validations
:else
{:ok {:pattern (first unique-patterns)}})))
一方、TypeScript側では、出力されたJSONを使用する際に、zod、yup、io-ts、effect などのライブラリを用いて対応するスキーマを定義できます。こうしたツールを使うことで、Clojureから出力されたデータも型安全に扱うことが可能です。
言語間でデータを受け渡す際の重要なポイントは、双方でスキーマを定義し、互換性を保つことです。ClojureでもTypeScriptでも、それぞれの方法でスキーマを用意し、期待するデータ構造や制約を明確にしておくことで、データの整合性や型安全性をより確実に担保できます。
まとめ:Clojure と TypeScript - データ変換における適材適所
本記事では、Figmaスタイルエクスポーターの実装を通じて、Clojureを使ったデータ変換処理の特徴と、静的型付け言語であるTypeScriptとの比較を紹介しました。
データ駆動型と型駆動型のアプローチ
Clojureはデータ駆動型のアプローチでも自然に記述できる言語であり、汎用的な関数を組み合わせて、データの構造や形を柔軟に変換・操作することが得意です。
- データ変換や構造変形を宣言的かつシンプルに記述できるため、複雑な階層や動的なデータ構造も容易に扱えます
- 型による事前制約に縛られず、データの構造やパターンが変化しても柔軟に対応できます
一方、TypeScriptは型駆動型のアプローチをとり、データ構造を厳密な型として定義し、その型に基づいてプログラムの正確性を保証します。
- 型システムを通じてデータ構造を記述し、設計意図を明確に表現します
- 静的型チェックにより、コンパイル時に多くのエラーを検出できるメリットがあります
おわりに
問題の性質に合った最適な道具を選ぶことは大切です。未知の要求に柔軟に対応できるデータ変換エンジンが必要な場面では、Lispという選択肢も検討する価値があるかもしれません。
Discussion