Panda CSSでデザイントークンの型制約を一部だけ緩めるプラグインを作成する

## はじめに
[Panda CSS](https://panda-css.com/)では[デザイントークンの埋め込み](https://panda-css.com/docs/theming/tokens)をサポートしています。
定義したデザイントークンには型がつきIDEなどで入力の補助をしてくれるのでデザイントークンのタイピングミス等を防げます。

さらに、設定ファイルで`strictTokens`を指定することでデザイントークン以外の値を利用できないような型の制約を課せます。

```ts
// ❌ デザイントークン以外の値は利用できない
css({ color: 'red' });
css({ color: 'sucess' });
css({ gap: '8px' });

// ⭕️ デザイントークンを利用する、もしくは値を[]で囲む
css({ color: 'success' });
css({ color: '[red]' });
css({ gap: 'sm' });

// ⭕️ 一部トークン以外で利用できる値が存在する
css({ width: 'screen' });
css({ width: '1/2' });
```

この制約はかなり厳しく、デザイントークンもしくはPanda CSSの定めた値(`witdh`の`screen`とか`1/2`のような値)を持つすべてのCSSプロパティの値に対して制約が課されます。
そのため、`width`や`height`で利用できるデザイントークン`sizes`を定義していない場合、デフォルトで持つ型情報以外の値を使う時には`[]`で囲む必要があります(CSS変数等も使えます)。
```ts
// ⭕️ 制約後でも使える値と[]で囲んだ場合
css({ width: 'screen' });
css({ width: '1/2' });
css({ width: '[256px]' });

// ❌ それ以外
css({ width: '256px' });
css({ width: '16rem' });
```

Panda CSSには`tokens`に定義可能なデザイントークンが20個強あります。これほどの種類がありますから、対象のデザイントークンを使いたくない場合や、デザイントークンを用いても自由な値も渡せるようにしたい場合は少なくないと思います。
しかし、それを汲み取って一部だけ型の制限を解除するようなオプションは用意されていませんから、`[]`を使わずに定義されていない値を使うためには`strictTokens`を外すしかありません。
そうするとデザイントークンだけを使いたい箇所でデザイントークン以外を使えてしまうので困っります。

さて、Panda CSSには`hooks`を利用することでコマンドのライフサイクルごとに実装を挟み込むことができます。この機能を利用すれば型ファイルを生成するタイミングに割り込んで、一部分だけデザイントークンによる型の制約をなくし、先ほどの悩みを解決してくれそうです。

この記事ではそのようなプラグインを作成する方法を紹介します。

## デザイントークン
デザイントークンは設定ファイルである`panda.config.ts`で定義します。

```ts:panda.config.ts
import { defineConfig } from '@pandacss/dev';
 
export default defineConfig({
  theme: {
    tokens: {
      colors: {
        primary: { value: '#50e2d2' },
        success: { value: '#15803D' },
        warning: { value: '#A16207' },
        error: { value: '#DC2626' },
      },
    },
  },
});
```

このように設定すると既存のデザイントークンの定義が削除され、宣言したものだけが使えるようになります(`textStyles`のような`tokens`と同じ階層のものは別です)。

https://play.panda-css.com/yswasSbx8x

既存のものと併用したい場合は`extend`を間に挟みます。

```ts
import { defineConfig } from '@pandacss/dev';
 
export default defineConfig({
  theme: {
    extend: {
      tokens: {
        colors: {
          primary: { value: '#50e2d2' },
          success: { value: '#15803D' },
          warning: { value: '#A16207' },
          error: { value: '#DC2626' },
        },
      },
    },
  },
});
```

https://play.panda-css.com/MQ0adYnWJt

## strictTokens
最初に記述しましたが、`strictTokens`はデザイントークンだけを使うような型を生成するように指示する設定です。

`sizes`についてのデザイントークンを定義していなくても、CSSの規定値である`100%`ですら型エラーが出るようになります。

```tsx
// ❌
<div className={css({ h: '100%' })}>

// ⭕️
<div className={css({ h: '[100%]' })}>
```

エラーは[Panda CSSのPlayground](https://play.panda-css.com/aFMxSg6AVF)からも確認できます。

この時に利用可能な値は`screen`や`1/2`のようなPanda CSSから与えられている一部の値と、`theme.tokens.sizes`と`theme.extend.tokens.sizes`に定義した値だけになります(`theme.tokens`に定義がない場合はデフォルトのデザイントークンの利用もできます)。

```ts:panda.config.ts
import { defineConfig } from '@pandacss/dev';
 
export default defineConfig({
  theme: {
    tokens: {
      sizes: {
        md: { value: '256px' },
      },
    },
  },
});
```
上記のように定義すると、`md`を`sizes`を使うCSSプロパティに渡せます(この記事で出てくるCSSプロパティとは標準のものではなく、Panda CSSに渡せるキーを指しています)。
```ts
// ⭕️
<div className={css({ h: 'md' })}>
````

`strictTokens`の挙動を確認できたところで、`strictTokens`が有効な時と無効な時で`h`に渡す値の型の違いを確認します。
CSSの各プロパティの型は`styled-system`(コードの生成先)の`types/style-props.d.ts`の`SystemProperties`で定義されています。
`h`の値の型は`strictTokens`を使っていない場合は以下のようになっています。
```ts
ConditionalValue<UtilityValues["height"] | CssVars | CssProperties["height"] | AnyString>
```
`strictTokens`を有効にしたときは以下のようになっています。
```ts
ConditionalValue<WithEscapeHatch<UtilityValues["height"] | CssVars>>
```
`strictTokens`の方は`WithEscapeHasch`の層が追加され、`CssProperties["height"]`と`AnyString`が排除されています。

```ts
export type WithEscapeHatch<T> = T | `[${string}]` | WithColorOpacityModifier<T> | WithImportant<T>

type WithColorOpacityModifier<T> = [T] extends [string] ? `${T}/${string}` & { __colorOpacityModifier?: true } : never

type ImportantMark = "!" | "!important"
type WhitespaceImportant = ` ${ImportantMark}`
type Important = ImportantMark | WhitespaceImportant
type WithImportant<T> = [T] extends [string] ? `${T}${Important}` & { __important?: true } : never
```

`WithEscapeHasch`は`[]`で囲んだあらゆる文字列の許可と`T`に追加して色のAlpha値、`important`フラグを設定することを可能にしています。

そして、削除された`CssProperties["height"]`は、CSSの標準で定義された値を使えるようにしています(少し長くなるのと、中身は重要じゃないので型の紹介は飛ばします)。
```ts
type AnyString = (string & {})
```
`AnyString`はプリミティブな値をIDEで予測変換をさせつつ、すべての文字列を受け入れ可能にしています。

つまり、`strictTokens`が有効のときはデフォルトで指定できる値を排除して`UtilityValues["height"]`と`CssVars`だけを許可し、`WithEscapeHatch`によって`[]`を利用するなどの例外箇所をサポートしています。

次に`strictTokens`で許可された値についてみていきます。
`CssVars`はCSS変数を利用するための型です。
```ts
type CssVars = `var(--${string})`
```

`UtilityValues["height"]`は`tokens.sizes`に定義した値とデフォルトでトークンに指定された値を提供します。
```ts
type UtilityValues["height"] = height: "auto" | Tokens["sizes"] | "svh" | "lvh" | "dvh" | "screen" | "1/2" | "1/3" | "2/3" | "1/4" | "2/4" | "3/4" | "1/5" | "2/5" | "3/5" | "4/5" | "1/6" | "2/6" | "3/6" | "4/6" | "5/6";
type Tokens["sizes"] = "md" | "breakpoint-sm" | "breakpoint-md" | "breakpoint-lg" | "breakpoint-xl" | "breakpoint-2xl"
```
:::note
`UtilityValues["height"]`に値が存在しないケースでは、デザイントークンを用いるCSSプロパティでも型の制約が行われません。
つまり、このケースに該当するデザイントークンの型の制約を行いたくない場合はデザイントークンを定義しないようにすることで、この記事で紹介するプラグインは不要になります。
:::

ここまでで`strictTokens`が有効な時に渡せる値と渡せない値、それぞれの型の情報が理解できたと思います。
確かに、この型制約がデザイントークンを定義していないプロパティで設けられると、`[]`を使わない限りは身動きが取れなそうですね。

## `sizes`に関係する型制約を緩和する
Panda CSSの`hooks`を利用して、型ファイルを生成するタイミングに割り込んで、デザイントークンのうち、`sizes`だけ型の制約をなくすようにします。

まずは、Panda CSSについての型情報を多く持つ`@pandacss/node`と`@pandacss/dev`をインストールします。

```command
npm i -D @pandacss/node @pandacss/dev
```

`@pandacss/dev`とバージョンを揃えて入れるようにしましょう。

次に、プラグインの入口の準備を作成します。

```ts
import type { PandaContext } from '@pandacss/node';
import type {
  LoggerInterface,
  PandaPlugin,
} from '@pandacss/types';

const pluginStrictTokensExcludeSizes = (): PandaPlugin => {
  let logger: LoggerInterface;
  let ctx: PandaContext;

  return {
    name: 'strict-tokens-exclude-sizes',
    hooks: {
      'context:created': (context) => {
        logger = context.logger;
        // @ts-expect-error -- ProcessorInterface型にcontextが存在しないため
        ctx = context.ctx.processor.context as PandaContext;
      },
      'codegen:prepare': (args) => {
        return transformPropTypes(args, ctx, logger);
      },
    },
  };
};
```
`context:created`は本処理で利用するために、`logger`とPanda CSSが生成する型情報や`shorthands`を持つ`PandaContext`取得しています。
実際の書き込みが始まる前に`codegen:prepare`で`transformPropTypes`という処理を割り込ませています。`transformPropTypes`でファイルに書き込む型情報を変更して`sizes`に関する型の制約をなくします。

`transformPropTypes`の全景は以下の通りです。
```ts
const transformPropTypes = (
  args: CodegenPrepareHookArgs,
  ctx: PandaContext,
  logger?: LoggerInterface,
) => {
  const artifact = args.artifacts.find((x) => x.id === 'types-styles');
  const content = artifact?.files.find((x) => x.file.includes('style-props'));
  if (!content?.code) return args.artifacts;

  const shorthandsByProp = new Map<string, string[]>();
  ctx.utility.shorthands.forEach((longhand, shorthand) => {
    shorthandsByProp.set(
      longhand,
      (shorthandsByProp.get(longhand) ?? []).concat(shorthand),
    );
  });

  const types = ctx.utility.getTypes();
  const excludeCategoryByProp = new Set<string>();
  types.forEach((values, prop) => {
    const categoryType = values.find((type) => type.includes('Tokens['));
    if (!categoryType) return;

    const tokenCategory = categoryType
      .replace('Tokens["', '')
      .replace('"]', '') as TokenCategory;
    if (tokenCategory !== 'sizes') return;

    excludeCategoryByProp.add(prop);
    const shorthands = shorthandsByProp.get(prop);

    if (!shorthands) return;
    shorthands.forEach((shorthand) => {
      excludeCategoryByProp.add(shorthand);
    });
  });

  const excludeTokenProps = Array.from(excludeCategoryByProp);
  if (!excludeTokenProps.length) return args.artifacts;

  if (logger) {
    logger.debug(
      'plugin',
      `🐼  Exclude token props: ${excludeTokenProps.join(', ')}`,
    );
  }

  const regex = /(\w+)\?: ConditionalValue<WithEscapeHatch<(.+)>>/g;

  content.code = content.code.replace(
    regex,
    (match, prop: string, value: string) => {
      if (excludeTokenProps.includes(prop)) {
        const longhand = ctx.utility.shorthands.get(prop);
        return `${prop}?: ConditionalValue<${value} | CssProperties["${longhand || prop}"]>`;
      }

      return match;
    },
  );

  return args.artifacts;
};
```

`codegen:parepare`は返ってきた`artifacts`をもとにファイルを生成するので、対象の型の制約が緩くなるように`artifacts`を書き換えます。

すべての処理を一度に解説するのは難解そうなのでいくつかに分けて解説します。
```ts
const artifact = args.artifacts.find((x) => x.id === 'types-styles');
const content = artifact?.files.find((x) => x.file.includes('style-props'));
if (!content?.code) return args.artifacts;
```
この部分は`types/style-props.d.ts`に書き込む予定のコードを取得する部分です。

`artifacts`はファイルに生成する情報を`helpers`や`cva`などのまとまりごとに持っています。
そのためスタイリングの型情報についての情報を持つ`types-styles`をピックアップして、その中から`style-props`を含む対象のファイルを探し出しています。

```ts
const shorthandsByProp = new Map<string, string[]>();
ctx.utility.shorthands.forEach((longhand, shorthand) => {
  shorthandsByProp.set(
    longhand,
    (shorthandsByProp.get(longhand) ?? []).concat(shorthand),
  );
});
```
続いての部分では`longhand`と`shorthand`を関連付けています。例えば`padding`だと`padding => ['p']`のようになります。

```ts
const types = ctx.utility.getTypes();
const excludeCategoryByProp = new Set<string>();
types.forEach((values, prop) => {
  const categoryType = values.find((type) => type.includes('Tokens['));
  if (!categoryType) return;

  const tokenCategory = categoryType
    .replace('Tokens["', '')
    .replace('"]', '') as TokenCategory;
  if (tokenCategory !== 'sizes') return;

  excludeCategoryByProp.add(prop);
  const shorthands = shorthandsByProp.get(prop);

  if (!shorthands) return;
  shorthands.forEach((shorthand) => {
    excludeCategoryByProp.add(shorthand);
  });
});
```
こちらの部分は除外するCSSプロパティを抽出しています。
はじめに、型の制約を持つCSSプロパティとそれが持つ値を`ctx.utility.getTypes()`で取得します。
```: heightはこんな感じ
'height' => [
  '"auto"', 'Tokens["sizes"]', '"svh"', '"lvh"',
  '"dvh"', '"screen"', '"1/2"', '"1/3"',
  '"2/3"', '"1/4"', '"2/4"', '"3/4"',
  '"1/5"', '"2/5"', '"3/5"', '"4/5"',
  '"1/6"', '"2/6"', '"3/6"', '"4/6"',
  '"5/6"',
],
```
`excludeCategoryByProp`は除外するCSSプロパティの集合です。

`forEach`の中では、型の制約を持つCSSプロパティの値から`Token["sizes"]`となっているものを探して、存在すれば`excludeCategoryByProp`にCSSプロパティを挿入しています。
存在した場合は先ほど作成した`shorthandsByProp`を活かして`shorthand`も`excludeCategoryByProp`に挿入します。

これで除外するCSSプロパティ一覧を取得できました。

```ts
const excludeTokenProps = Array.from(excludeCategoryByProp);
if (!excludeTokenProps.length) return args.artifacts;
```
以後の処理で扱いやすいように除外するプロパティを配列に直し、プロパティが存在しなければ何もする必要がないので`artifacts`をそのまま返します。

```ts
const regex = /(\w+)\?: ConditionalValue<WithEscapeHatch<(.+)>>/g;

content.code = content.code.replace(
  regex,
  (match, prop: string, value: string) => {
    if (excludeTokenProps.includes(prop)) {
      const longhand = ctx.utility.shorthands.get(prop);
      return `${prop}?: ConditionalValue<${value} | CssProperties["${longhand || prop}"] | AnyString>`;
    }

    return match;
  },
);

return args.artifacts;
```
最後に、`types/style-props.d.ts`の対象のコードを書き換えます。
`regex`で`WithEscapeHatch`を使っている部分、つまりデザイントークンによる型の強い制約を与えている部分を抜き出して、そこに除外するプロパティが含まれていた場合にコードの書き換えを行なっています。
書き換えは、`WithEscapeHatch`の除外と`CssProperties`と`AnyString`を追加して、`strictTokens`を指定しなかった状態にします。

以上でプラグインの作成完了です。

```ts:panda.config.ts
import { defineConfig } from '@pandacss/dev';
 
export default defineConfig({
  theme: {
    tokens: {
      colors: {
        primary: { value: '#50e2d2' },
        success: { value: '#15803D' },
        warning: { value: '#A16207' },
        error: { value: '#DC2626' },
      },
      sizes: {
        md: { value: '256px' },
      },
    },
  },
  strictTokens: true,
  plugins: [pluginStrictTokensExcludeSizes()],
});
```
このように定義すると、`color`はデザイントークンの値以外は利用できないが、`height`や`w`には好きな文字列を利用できる型情報が生成されます。
```ts
// ❌ デザイントークン以外の値は利用できない
css({ color: 'red' });
css({ gap: '8px' });

// ⭕️ デザイントークンを利用する、もしくは値を[]で囲む
css({ color: 'primary' });
css({ color: '[red]' });
css({ width: 'md' });

// ⭕️ 一部トークンが以外で利用できる値が存在する
css({ marginLeft: 'auto' });

// ⭕️ sizesのトークンを利用するCSSプロパティはなんでも渡せる
css({ width: '256px' });
css({ width: '1.5rem' });
```

## 一部のデザイントークンと要素の型制約を緩和する
より一般的な形にします。型の制約から解き放ちたいデザイントークンとCSSプロパティを外から与えられるようにします。

```ts
import type { PandaContext } from '@pandacss/node';
import type {
  CodegenPrepareHookArgs,
  CssProperties,
  LoggerInterface,
  PandaPlugin,
  TokenCategory,
} from '@pandacss/types';

type StrictTokensExcludeOptions = {
  categories?: TokenCategory[];
  props?: Array<keyof CssProperties | (string & object)>;
};

export const pluginStrictTokensExclude = (
  options: StrictTokensExcludeOptions,
): PandaPlugin => {
  let logger: LoggerInterface;
  let ctx: PandaContext;

  return {
    name: 'strict-tokens-exclude',
    hooks: {
      'context:created': (context) => {
        logger = context.logger;
        // @ts-expect-error -- ProcessorInterface型にcontextが存在しないため
        ctx = context.ctx.processor.context as PandaContext;
      },
      'codegen:prepare': (args) => {
        return transformPropTypes(args, options, ctx, logger);
      },
    },
  };
};

const transformPropTypes = (
  args: CodegenPrepareHookArgs,
  options: StrictTokensExcludeOptions,
  ctx: PandaContext,
  logger?: LoggerInterface,
) => {
  const { categories = [], props = [] } = options;
  if (!categories.length && !props.length) return args.artifacts;

  const artifact = args.artifacts.find((x) => x.id === 'types-styles');
  const content = artifact?.files.find((x) => x.file.includes('style-props'));
  if (!content?.code) return args.artifacts;

  const shorthandsByProp = new Map<string, string[]>();
  ctx.utility.shorthands.forEach((longhand, shorthand) => {
    shorthandsByProp.set(
      longhand,
      (shorthandsByProp.get(longhand) ?? []).concat(shorthand),
    );
  });

  const types = ctx.utility.getTypes();
  const excludeCategoryByProp = new Set<string>(props);
  types.forEach((values, prop) => {
    const categoryType = values.find((type) => type.includes('Tokens['));
    if (!categoryType) return;

    const tokenCategory = categoryType
      .replace('Tokens["', '')
      .replace('"]', '') as TokenCategory;
    if (!categories.includes(tokenCategory)) {
      return;
    }

    excludeCategoryByProp.add(prop);
    const shorthands = shorthandsByProp.get(prop);

    if (!shorthands) return;
    shorthands.forEach((shorthand) => {
      excludeCategoryByProp.add(shorthand);
    });
  });

  const excludeTokenProps = Array.from(excludeCategoryByProp);
  if (!excludeTokenProps.length) return args.artifacts;

  if (logger) {
    logger.debug(
      'plugin',
      `🐼  Exclude token props: ${excludeTokenProps.join(', ')}`,
    );
  }

  const regex = /(\w+)\?: ConditionalValue<WithEscapeHatch<(.+)>>/g;

  content.code = content.code.replace(
    regex,
    (match, prop: string, value: string) => {
      if (excludeTokenProps.includes(prop)) {
        const longhand = ctx.utility.shorthands.get(prop);
        return `${prop}?: ConditionalValue<${value} | CssProperties["${longhand || prop}"]>`;
      }

      return match;
    },
  );

  return args.artifacts;
};
```

`pluginStrictTokensExclude`を呼び出すときに`categories`と`props`を渡せるようにしました。

変更点は2箇所だけです。
1点目は`ctx.utility.getTypes()`で`forEach`を回している時に、対象のデザイントークンを探すところを`tokenCategory !== 'sizes'`から`includes`を使ったものに変更しました。
```ts
!categories.includes(tokenCategory)
```

2点目は`excludeCategoryByProp`を初期化するタイミングで`props`を追加するようにしたところです。
```ts
const excludeCategoryByProp = new Set<string>(props);
```

このプラグインを`pluginStrictTokensExcludeSizes`と同じ設定にするには以下のようにします。
```ts:panda.config.ts
import { defineConfig } from '@pandacss/dev';
 
export default defineConfig({
  theme: {
    tokens: {
      colors: {
        primary: { value: '#50e2d2' },
        success: { value: '#15803D' },
        warning: { value: '#A16207' },
        error: { value: '#DC2626' },
      },
      sizes: {
        md: { value: '256px' },
      },
    },
  },
  strictTokens: true,
  plugins: [pluginStrictTokensExclude({ categories: ['sizes'] })],
});
```
さらに、デザイントークンでは`shadows`、CSSプロパティでは`top`・`left`・`bottom`・`right`の型情報を緩めたいときは以下のようにします。
```ts:panda.config.ts
import { defineConfig } from '@pandacss/dev';
 
export default defineConfig({
  theme: {
    extend: {
      tokens: {
        colors: {
          primary: { value: '#50e2d2' },
          success: { value: '#15803D' },
          warning: { value: '#A16207' },
          error: { value: '#DC2626' },
        },
        sizes: {
          md: { value: '256px' },
        },
      },
    },
  },
  strictTokens: true,
  plugins: [
    pluginStrictTokensExclude({
      categories: ['sizes', 'shadows'],
      props: ['top', 'left', 'bottom', 'right'],
    }),
  ],
});
```
`sizes`と`shadows`を用いるCSSプロパティと`top`・`left`・`bottom`・`right`に自由な値を渡せます。`top`・`left`・`bottom`・`right`と同じデザイントークン`spacing`を持つ`gap`の型には制約は残っていることも確認してください。
```ts
// ❌ デザイントークン以外の値は利用できない
css({ color: 'red' });
css({ gap: '8px' });

// ⭕️ sizes・shadowのトークンを利用するCSSプロパティはなんでも渡せる
css({ width: '256px' });
css({ width: '1.5rem' });
css({ boxShadow: '10px 5px 5px red' });

// ⭕️ top・left・bottom・rightはなんでも渡せる
css({ top: '0px', left: '0px' });
css({ bottom: '50%', right: 'md' });
```

## さいごに
Panda CSSでデザイントークンの型制約を一部だけ緩めるプラグインを作成する方法を紹介しました。

既存の型ファイルの生成方法に依存していますからバージョンアップに伴ったメンテナンスが必要になることがネックですが、これにより型安全で快適な開発を進めることができるので、同じような悩みを持っている方は利用してみてはいかがでしょうか。

`hooks`を用いた実装になっているのでカスタマイズもしやすいです。各々が実現したい世界を実現できると良いですね。

## 参考
今回紹介したプラグインは[pandabox](https://github.com/astahmer/pandabox)を参考に作成しました。