くらげになりたい。

くらげのようにふわふわ生きたい日曜プログラマなブログ。趣味の備忘録です。

Pinceauを少しのぞいてみたら、style-dictionaryをつかってた

Pinceauを少しさわってみた

Nuxt ThemesDoucsで利用されているPinceau
Docusをカスタマイズするために、もう少しちゃんと理解しようと、
少し触ってみたときの備忘録(´ω`)

Pinceau

デザイントークンのライブラリ。
こんな感じでtokens.configを用意すると、

// tokens.config.ts
import { defineTheme } from 'pinceau'
defineTheme({
  // Media queries
  media: {
    mobile: '(min-width: 320px)',
    tablet: '(min-width: 768px)',
    desktop: '(min-width: 1280px)'
  },
  // Some Design tokens
  color: {
    red: {
      1: '#FCDFDA',
      2: '#F48E7C',
      3: '#ED4D31',
      4: '#A0240E',
      5: '#390D05',
    },
  },
  primary: {
    1: '{color.red.1}',
    2: '{color.red.2}'
  },
  space: {
    1: '0.25rem',
    2: '0.5rem',
    3: '0.75rem',
    4: '1rem'
  },
  // Utils properties
  utils: {
    px: (value: PropertyValue<'padding'>) => ({ paddingLeft: value, paddingRight: value }),
    py: (value: PropertyValue<'padding'>) => ({ paddingTop: value, paddingBottom: value })
  }
});

こんな感じ($dt())、Vueのコンポーネント内からアクセスできる。

<style lang="postcss">
button {
  background-color: $dt('red.1');
  @desktop {
    padding: $dt('space.2') $dt('space.4');
  }
}
</style>

それぞれCSS変数になっているので、
<template>内で上書きすることもできる。

<template>
  <MyButton :css="{ '--button-primary': '{color.red.200}' }" />
</template>

css()を使うと、objectをcssに変換できるので、
propsとかも使える感じ。

<style lang="ts">
css({
  '.my-button': {
    px: '{space.4}',
    backgroundColor: (props) => `{color.${props.color}.4}`,
  }
})
</style>

<script setup>
defineProps({
  color: computedStyle<keyof PinceauTheme['color']>('green'),
  ...variants
})
</script>

ビルド時にCSSだけでなく、型定義なども出力してくれるので、
これらのデザイントークンをタブ補完できるようになっている。

pinceau/nuxtモジュール

Nuxtモジュールも用意されているので導入も簡単。

// nuxt.config.ts
defineNuxtConfig({
  modules: ['pinceau/nuxt'],
  pinceau: {
    ...options
  }
})

ビルド時に生成するファイル

ビルドをするとtokens.config.tsから、
以下のファイルを生成する。

# Docusの雛形の場合
$ tree .nuxt/pinceau/ --dirsfirst
.nuxt/pinceau/
├── theme
│   └── index.css   ... CSS変数
├── index.ts        ... マージ/正規化されたtokens.config
├── utils.ts        ... tokens.configのutils部分
├── definitions.ts  ... VSCode拡張機能用
└── schema.ts       ... Nuxt Studio用。index.tsのJSON Scheme?

それぞれの冒頭の数行はこんな感じ。

theme/index.css

@media {
  :root {
    --pinceau-mq: initial;
    --docus-loadingBar-gradientColorStop3: #0047e1;
    --docus-loadingBar-gradientColorStop2: #34cdfe;

index.ts

export const theme = {
  "media": {
    "xs": {
      "value": "(min-width: 475px)",
      "variable": "var(--media-xs)",
      "raw": "(min-width: 475px)"
    },
    "sm": {
      "value": "(min-width: 640px)",
      "variable": "var(--media-sm)",
      "raw": "(min-width: 640px)"
    },

utils.ts

import { PinceauTheme, PropertyValue } from 'pinceau'

export const my = (value: PropertyValue<'margin'>) => {
  return {
    marginTop: value,
    marginBottom: value,
  }
}

Multi layer theming

こんな感じで設定すると、
複数のtokens.config.tsをマージしてくれるっぽい。

{
  configLayers: [
    // `string`
    './my-themes/basic/',
    // `ConfigLayer` with `tokens`
    {
      tokens: { 
        color: { primary: 'red' }
      }
    },
    // `ConfigLayer` with `cwd`
    {
      cwd: './my-themes/figma',
      configFileName: 'figma.config'
    }
  ]
}

Docusの場合は、layerになってる感じ。
一番下のレイヤーから、

最終的にどんなtokens.configになるかは、
生成ファイルのtheme/index.cssindex.tsutils.tsをみるとよさそう。

StyleDictionaryで生成しているよう

ソースをのぞいてみると、Style Dictionaryを使って、
各ファイルを生成しているよう。

import type { Core as Instance } from 'style-dictionary-esm'
import StyleDictionary from 'style-dictionary-esm'

export async function generateTheme(
  tokens: any,
  definitions: any,
  // ...
): Promise<ThemeGenerationOutput> {
  let styleDictionary: Instance = StyleDictionary

  // Files created by Pinceau
  const files = [
    {
      destination: 'theme/index.css',
      // Has to be named `css` to be recognized as CSS output
      format: 'css',
    },
    {
      destination: 'index.ts',
      format: 'pinceau/typescript',
    },
    {
      destination: 'utils.ts',
      format: 'pinceau/utils',
    },
  ]
  // ...
}

READMEにも、

This package takes a lot of inspiration from these amazing projects:

と書かれているので、tokens.config.tsの中身も、
style-dictionaryの形式に従っているっぽい?

仮説というか妄想

最終的にはTailwindも併用したい。
Tailwindには便利なUtilが揃っているので、
トライアンドエラーで開発するには便利。

Issueにあがっているけど、
もしかしたらTailwindのtokens.config.tsが用意されるかもしれない

また、style-dictionary → TailwindのThemeへの変換はあるっぽいので、
うまく使うといいかも?

tokens.config.tsを起点に、Style Dictionaryで、

  • TailwindのTheme
  • Flutter用の.dart

みたいなこともできそう?

ビルド中のhook

オプションを見ると、configとビルド結果を扱えるよう。

import fs from "node:fs";

export default defineNuxtConfig({
  extends: '@nuxt-themes/docus',
  pinceau: {
    // - After all your configLayers has been merged together
    // - Before generateTheme is called to generate all of your theme outputs
    // Before generateTheme is called to generate all of your theme outputs
    configResolved: (config: LoadConfigResult<PinceauTheme>) => {
      fs.writeFileSync("./.nuxt/pinceau-config-resolved.json", JSON.stringify(config, null, 2));
    },
    // A function that will be called when your theme is built.
    // That is useful if you want to listen to the Pinceau outputs.
    configBuilt: (config: ThemeGenerationOutput) => {
      fs.writeFileSync("./.nuxt/pinceau-config-built.json", JSON.stringify(config, null, 2));
    }
  }
})

型定義はこんな感じ。

// GeneratedPinceauTheme = 生成されたindex.tsのtheme
export type PinceauTheme = GeneratedPinceauTheme

/**
 * A configuration data once loaded by Pinceau.
 */
export interface LoadConfigResult<T = any> {
  // マージされたtokens.config.ts
  config: T
  // configから生成されたdefinisions.tsの中身
  definitions: { [key: string]: any }
  // configLayersの元のパス
  sources: string[]
}

export interface ThemeGenerationOutput {
  buildPath: string
  tokens: any
  outputs: Record<string, any>
}

generateTheme()が呼ばれている箇所をみるとこんな感じ。

message('CONFIG_RESOLVED', [resolvedConfig])
// ...
const builtTheme = await generateTheme(resolvedConfig.config, resolvedConfig.definitions, options)
if (options?.configBuilt) { options.configBuilt(builtTheme) }
  • configResolved ... generateTheme()=style-dictionaryの引数で使われる
  • configBuilt ... generateTheme()=style-dictionaryの結果

それを踏まえて、もう一度generateThemeをみてみる。

import StyleDictionary from 'style-dictionary-esm'

export async function generateTheme(
  tokens: any,
  // ...
): Promise<ThemeGenerationOutput> {
  let styleDictionary: Instance = StyleDictionary

  // ... 略
  // registerFormatとかregisterTransformとか
  // StyleDictionaryの設定
  // ... 略
  
  // Responsive tokens
  const mqKeys = ['dark', 'light', ...Object.keys(tokens?.media || {})]
  
  // schema.ts
  const schema = await resolveUntypedSchema({ tokensConfig: tokens })

  // ... 略
  
  // 
  styleDictionary = styleDictionary.extend({
    // 
    tokens: normalizeConfig(tokens, mqKeys, true),
    // ...略
  })
  
  try {
    result = await new Promise<ThemeGenerationOutput>(
      (resolve) => {
        styleDictionary.registerAction({
          name: 'done',
          do: ({ tokens }) => {
            // configBuiltに渡される引数
            resolve
              tokens: flattenTokens(tokens),
              outputs,
              buildPath,
            })
          },
          undo: () => {},
        })
        styleDictionary.buildAllPlatforms()
      },
    )
  }
  // ...

  return result
}

直接ではなく、カスタムformatやtransformがあるけど、
なんとか行けそうな感じ。

ざっくり、それぞれの結果をファイルに吐き出すようにして、

import fs from "node:fs";

export default defineNuxtConfig({
  extends: '@nuxt-themes/docus',

  pinceau: {
    configResolved: (config: LoadConfigResult<PinceauTheme>) => {
      fs.writeFileSync("./.nuxt/pinceau-config-resolved-config.json", JSON.stringify(config.config, null, 2));
    },
    configBuilt: (config: ThemeGenerationOutput) => {
      fs.writeFileSync("./.nuxt/pinceau-config-built-tokens.json", JSON.stringify(config.tokens, null, 2));
    }
  }
})

自前でstyle-dictionaryを実行するスクリプトを用意。

// scripts/build-sd.ts
import type { Core as Instance } from 'style-dictionary-esm'
import StyleDictionary from 'style-dictionary-esm';
import builtTokens from "../.nuxt/pinceau-config-built-tokens.json";

// ****************************
// * MAIN
// ****************************
async function main() {
  let styleDictionary: Instance = StyleDictionary

  styleDictionary = styleDictionary.extend({
    tokens: builtTokens,
    platforms: {
      css: {
        transformGroup: "css",
        buildPath: ".sd-build/css/",
        files: [
          { destination: "_variables.css", format: "css/variables", },
        ],
      },
    },
  })

  styleDictionary.buildAllPlatforms();
}

main().then();

実行してみるとこんな感じ。

$ pnpm jiti scripts/build-sd.ts
css
✔︎ .sd-build/css/_variables.css 
$ head -n 20 .sd-build/css/_variables.css
:root {
  --media-xs: (min-width: 475px);
  --media-sm: (min-width: 640px);
  --media-md: (min-width: 768px);
  --media-lg: (min-width: 1024px);
  --media-xl: (min-width: 1280px);
  --media-2xl: (min-width: 1536px);
  --media-rm: (prefers-reduced-motion: reduce);
  --media-landscape: only screen and (orientation: landscape);
  --media-portrait: only screen and (orientation: portrait);
  --color-white: #ffffff;
  --color-black: #0b0a0a;
  --color-gray-50: #fbfbfb;
  --color-gray-100: #f6f5f4;
  --color-gray-200: #ecebe8;

それっぽい何かは出せるっぽい。

もう少しgenerateThemeのカスタムtransformやformatを移植すると、
他のプラットフォーム用のファイルもtokens.config.tsから生成できそうかも。


以上!! 便利だけど、いろんなのを考えると難しいね(*´ω`*)