Skip to content

Commit

Permalink
Add Figma Variables support (#189)
Browse files Browse the repository at this point in the history
* Add Figma Variables support

* Improve docs

* Fix Windows tests?
  • Loading branch information
drwpow authored Feb 5, 2024
1 parent a395b21 commit a2afdc4
Show file tree
Hide file tree
Showing 21 changed files with 2,725 additions and 31 deletions.
6 changes: 6 additions & 0 deletions .changeset/cuddly-walls-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@cobalt-ui/plugin-css": patch
"@cobalt-ui/utils": patch
---

Move glob matching to @cobalt-ui/utils
5 changes: 5 additions & 0 deletions .changeset/ninety-ducks-explain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cobalt-ui/cli": patch
---

Remove accidental console.log
6 changes: 6 additions & 0 deletions .changeset/sharp-parents-poke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@cobalt-ui/core": minor
"@cobalt-ui/cli": minor
---

Add Figma Variable support natively
145 changes: 140 additions & 5 deletions docs/integrations/figma.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,152 @@ title: Figma Integration

# Figma Integration

Because Figma doesn’t have a way to export the [Design Tokens Community Group format (DTCG)](https://designtokens.org) directly, you’ll need a plugin to export your styles to the DTCG format.
Cobalt supports the following export methods from Figma:

The plugin we recommend for now is [Tokens Studio for Figma](https://tokens.studio). Though it doesn’t support DTCG directly either, it does allow you to export your design tokens in a format Cobalt can read.
1. **Styles**: no support¹. Variables are recommended.
2. **Variables**: [see docs](#figma-variables)
3. **Tokens Studio**: [see docs](#tokens-studio)

::: info

This only allows syncing _from_ Figma. Syncing _to_ Figma isn’t possible today, but the Cobalt team is actively building something to make this possible. Stay tuned! 📺
¹ Beta versions of Cobalt had Styles support, before Variables were announced. But Figma’s API made it not only difficult to convert Styles to DTCG, it also required more setup and headache for the designer and developer. However, Variables were built with the DTCG spec in mind, and couldn’t be easier.

:::

## Exporting from Tokens Studio
## Figma Variables

::: warning

Using the Figma Variables API currently [requires an Enterprise plan](https://www.figma.com/developers/api#variables) in Figma.

:::

[Figma Variables](https://help.figma.com/hc/en-us/articles/15339657135383-Guide-to-variables-in-Figma) are currently in beta, but were designed to match the DTCG token format 1:1, and are expected to follow changes. Currently, the supported token types are:

| Figma Type | DTCG Type | Notes |
| :--------: | :---------: | :------------------------------ |
| `color` | `color` | Coverts to sRGB Hex by default. |
| `number` | `dimension` | Uses `px` by default. |
| `string` | | Ignored if no `type` specified. |
| `boolean` | | Ignored if no `type` specified. |

_Note: [typography variables](https://help.figma.com/hc/en-us/articles/4406787442711#variables) have been announced, but aren’t released yet. Cobalt will add support when they arrive._

### Setup

In your `tokens.config.js` file, add the Figma [share URL](https://help.figma.com/hc/en-us/articles/360040531773-Share-files-and-prototypes) as a token source:

```ts
/** @type {import('@cobalt-ui/core').Config} */
export default {
tokens: ['https://www.figma.com/file/OkPWSU0cusQTumCNno7dm8/Design-System?…'],
};
```

Next, you’ll need to create a [Figma Access Token](https://www.figma.com/developers/api#access-tokens) with the `file:read` and `file_variables:read` scopes and expose it as `FIGMA_ACCESS_TOKEN` in your `.zshrc` or `.bashrc` file (or in CI you can add this to [GitHub Actions Secrets](https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions))

```sh
export FIGMA_API_TOKEN=abc123…
```

Then run `co build` as you would normally, and Cobalt will operate as if the Variables pulled from Figma existed in a local `tokens.json` file.

### Overrides

Figma Variables can be a **Color**, **Number**, **String**, or **Boolean.** Color translates directly to the DTCG [Color](/tokens/color) type, so those will work automatically. But for everything else, you’ll need to set up overrides to specify what token type each Figma Variable should be. To do so, specify selectors in a mapping in `figma.overrides` where the key is a glob pattern (or specific ID), and the value is an object with your desired DTCG type:

```ts
/** @type {import('@cobalt-ui/core').Config} */
export default {
tokens: ['https://www.figma.com/file/OkPWSU0cusQTumCNno7dm8/Design-System?…'],
figma: {
overrides: {
'size/*': {$type: 'dimension'},
'timing/*': {$type: 'duration'},
},
},
};
```

::: tip

If both a glob pattern and specific ID are provided, the specific ID takes priority.

:::

#### Advanced Overrides

`figma.overrides` also accepts 2 callback utilities to provide futher control over transforming Variables:

##### `rename()`

By default, tokens will keep the same name as your Figma Variables, but with `/` converted into `.`, e.g. `color/base/blue/500``color.base.blue.500`. But to rename certain tokens, you can provide a `transformID()` utility:

```ts
/** @type {import('@cobalt-ui/core').Config} */
export default {
tokens: ['https://www.figma.com/file/OkPWSU0cusQTumCNno7dm8/Design-System?…'],
figma: {
overrides: {
'color/*': {
// rename color/base/purple → color/base/violet
rename(id) {
return id.replace('color/base/purple', 'color/base/violet');
},
},
},
},
};
```

You can choose to keep the `/`s from Figma, or convert to `.` separators like DTCG requires; up to you. They both work the same way.

::: tip

If you return `undefined` or an empty string, it’ll keep its original name.

:::

##### `transform()`

This is useful when either `$type` isn’t enough, or you want to provide additional conversions. Here, for example, is how you’d take `px`-based number Variables and convert to `rem`s:

```ts
/** @type {import('@cobalt-ui/core').Config} */
export default {
tokens: ['https://www.figma.com/file/OkPWSU0cusQTumCNno7dm8/Design-System?…'],
figma: {
overrides: {
'size/*': {
$type: 'dimension',
// convert px → rem
transform({variable, collection, mode}) {
const rawValue = variable.valuesByMode[mode.modeId];
if (typeof rawValue === 'number') {
return `${rawValue / 16}rem`;
}
// remember rawValue may be an alias of another Variable!
// in that case, `typeof rawValue === "object" && rawValue.type === 'VARIABLE_ALIAS'`
},
},
},
},
};
```

::: info

`transform()` will only run a maximum of 1× per variable (you can’t do multiple runs with multiple matching globs).

:::

::: tip

You can even create aliases on-the-fly by either returning a DTCG alias string `"{color.base.blue}"`, or a Figma Variable alias type (`{ type: "VARIABLE_ALIAS", id: "xxxxxxx…" }`).

:::

## Tokens Studio

Once your design tokens are in Tokens Studio ([docs](https://docs.tokens.studio/tokens/creating-tokens)), use [any of the approved sync methods](https://docs.tokens.studio/sync/sync) to export a `tokens.json` file. Then use Cobalt as you would normally:

Expand All @@ -31,7 +166,7 @@ export default {

Once your sync method is set up, it should be a snap to re-export that `tokens.json` file every time something updates.

## Support
### Support

| Tokens Studio Type | Supported | Notes |
| :-------------------------------------------------------------------------------- | :-------: | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
**/tokens/**/*
test/fixtures/bundle-default/given
test/fixtures/figma-error/given.json
test/fixtures/figma-success/given.jso
test/fixtures/style-dictionary/given.json
38 changes: 32 additions & 6 deletions packages/cli/bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const flags = parser(args, {
});

/** `tokens` CLI command */
async function main() {
export default async function main() {
const start = performance.now();

// ---
Expand Down Expand Up @@ -191,7 +191,6 @@ async function main() {
process.exit(1);
}

console.log(process.argv, args);
const input = JSON.parse(fs.readFileSync(new URL(args[0], cwd), 'utf8'));
const {errors, warnings, result} = await convert(input);
if (errors) {
Expand Down Expand Up @@ -294,12 +293,39 @@ async function loadTokens(tokenPaths) {
const isYAMLExt = pathname.endsWith('.yaml') || pathname.endsWith('.yml');
if (filepath.protocol === 'http:' || filepath.protocol === 'https:') {
try {
const res = await globalThis.fetch(filepath, {method: 'GET', headers: {Accept: '*/*', 'User-Agent': 'Mozilla/5.0 Gecko/20100101 Firefox/116.0'}});
// if Figma URL
if (filepath.host === 'figma.com' || filepath.host === 'www.figma.com') {
const [_, fileKeyword, fileKey] = filepath.pathname.split('/');
if (fileKeyword !== 'file' || !fileKey) {
printErrors(`Unexpected Figma URL. Expected "https://www.figma.com/file/:file_key/:file_name?…", received "${filepath.href}"`);
process.exit(1);
}
const headers = new Headers({Accept: '*/*', 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:123.0) Gecko/20100101 Firefox/123.0'});
if (process.env.FIGMA_ACCESS_TOKEN) {
headers.set('X-FIGMA-TOKEN', process.env.FIGMA_ACCESS_TOKEN);
} else {
printWarnings(`FIGMA_ACCESS_TOKEN not set`);
}
const res = await fetch(`https://api.figma.com/v1/files/${fileKey}/variables/local`, {method: 'GET', headers});
if (res.ok) {
const data = await res.json();
rawTokens.push(data.meta);
continue;
}
const message = res.status !== 404 ? JSON.stringify(await res.json(), undefined, 2) : '';
printErrors(`Figma responded with ${res.status}${message ? `:\n${message}` : ''}`);
process.exit(1);
break;
}

// otherwise, expect YAML/JSON
const res = await fetch(filepath, {method: 'GET', headers: {Accept: '*/*', 'User-Agent': 'Mozilla/5.0 Gecko/20100101 Firefox/123.0'}});
const raw = await res.text();
if (isYAMLExt || res.headers.get('content-type').includes('yaml')) {
rawTokens.push(yaml.load(raw));
} else {
// if the 1st character is '{', it’s JSON (“if it’s dumb but it works…”)
if (raw[0].trim() === '{') {
rawTokens.push(JSON.parse(raw));
} else {
rawTokens.push(yaml.load(raw));
}
} catch (err) {
printErrors(`${filepath.href}: ${err}`);
Expand Down
16 changes: 15 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,20 @@
"name": "Drew Powers",
"email": "[email protected]"
},
"keywords": ["design tokens", "design tokens community group", "design tokens format module", "dtcg", "cli", "w3c design tokens", "design system", "typescript", "sass", "css", "style tokens", "style system"],
"keywords": [
"design tokens",
"design tokens community group",
"design tokens format module",
"dtcg",
"cli",
"w3c design tokens",
"design system",
"typescript",
"sass",
"css",
"style tokens",
"style system"
],
"engines": {
"node": ">=18.0.0"
},
Expand Down Expand Up @@ -46,6 +59,7 @@
"@types/culori": "^2.0.4",
"@types/node": "^20.11.16",
"execa": "^7.2.0",
"msw": "^2.1.5",
"vitest": "^1.2.2"
}
}
2 changes: 1 addition & 1 deletion packages/cli/test/check.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe('co check', () => {
});

it('URL', async () => {
const result = await execa('node', ['./bin/cli.js', 'check', 'https://raw.githubusercontent.com/drwpow/cobalt-ui/main/packages/cli/it/fixtures/check-default/tokens.json']);
const result = await execa('node', ['./bin/cli.js', 'check', 'https://raw.githubusercontent.com/drwpow/cobalt-ui/main/packages/cli/test/fixtures/check-default/tokens.json']);
expect(result.exitCode).toBe(0);
});

Expand Down
50 changes: 50 additions & 0 deletions packages/cli/test/figma.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import fs from 'node:fs';
import os from 'node:os';
import {fileURLToPath} from 'node:url';
import {http, HttpResponse} from 'msw';
import {setupServer} from 'msw/node';
import {describe, expect, it, vi} from 'vitest';
import FIGMA_VARIABLE_API_RESPONSE from './fixtures/figma-success/api_v1_response.json';

const FILE_KEY = 'OkPWSU0cusQTumCNno7dm8';

// note: running execa like in the other tests doesn’t let us mock the fetch() call with msw.
// so we mock the CLI variables first, then load the JS module in the text scope

// bug in Node VM on Windows: importing module blows up (errs after `new Script node:vm:99:7, createScript node:vm:255:10, Object.runInThisContext node:vm:303:10`)

describe('Figma import', () => {
it.skipIf(os.platform() === 'win32')('parses valid syntax correctly', async () => {
const cwd = new URL('./fixtures/figma-success/', import.meta.url);
const server = setupServer(http.get(`https://api.figma.com/v1/files/${FILE_KEY}/variables/local`, () => HttpResponse.json(FIGMA_VARIABLE_API_RESPONSE)));
server.listen();

// run CLI
process.argv = ['node', 'bin/cli.js', 'build', '--config', fileURLToPath(new URL('./tokens.config.js', cwd))];
process.exit = vi.fn();
const mod = await import('../bin/cli.js');
await mod.default();

expect(fs.readFileSync(new URL('./given.json', cwd), 'utf8')).toMatchFileSnapshot(fileURLToPath(new URL('./want.json', cwd)));

// clean up
server.close();
});

it.skipIf(os.platform() === 'win32')('throws errors on invalid response', async () => {
const cwd = new URL('./fixtures/figma-error/', import.meta.url);
const server = setupServer(http.get(`https://api.figma.com/v1/files/${FILE_KEY}/variables/local`, () => HttpResponse.json({status: 401, error: true}, {status: 401})));
server.listen();

// run CLI
process.argv = ['node', 'bin/cli.js', 'build', '--config', fileURLToPath(new URL('./tokens.config.js', cwd))];
process.exit = vi.fn();
const mod = await import('../bin/cli.js');
await mod.default();

expect(process.exit).toHaveBeenCalledWith(1);

// clean up
server.close();
});
});
12 changes: 12 additions & 0 deletions packages/cli/test/fixtures/figma-error/tokens.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import pluginJS from '../../../../../packages/plugin-js/dist/index.js';

/** @type {import('@cobalt-ui/core').Config} */
export default {
tokens: ['https://www.figma.com/file/OkPWSU0cusQTumCNno7dm8/Variable-Export?type=design&node-id=0%3A1&mode=design&t=zxhnYAf1FASSHySQ-1'],
outDir: '.',
plugins: [
pluginJS({
json: './given.json',
}),
],
};
Loading

0 comments on commit a2afdc4

Please sign in to comment.