figma-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +22 -0
- data/CLAUDE.md +75 -0
- data/README.md +128 -0
- data/Rakefile +12 -0
- data/exe/figma-cli +6 -0
- data/lib/figma/cli/client.rb +64 -0
- data/lib/figma/cli/command.rb +46 -0
- data/lib/figma/cli/errors.rb +11 -0
- data/lib/figma/cli/extractors/color_converter.rb +24 -0
- data/lib/figma/cli/extractors/colors.rb +22 -0
- data/lib/figma/cli/extractors/effects.rb +48 -0
- data/lib/figma/cli/extractors/spacing.rb +24 -0
- data/lib/figma/cli/extractors/typography.rb +24 -0
- data/lib/figma/cli/parser.rb +123 -0
- data/lib/figma/cli/version.rb +7 -0
- data/lib/figma/cli/writer.rb +43 -0
- data/lib/figma/cli.rb +18 -0
- data/sig/figma/cli.rbs +6 -0
- metadata +111 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d4174adfd2513d05fd36e0392d1bc2be14d6e495b26131682626cd9082046a7b
|
|
4
|
+
data.tar.gz: dfb6f841bc0f27b7a1bf9603de28b059182ff9add04ef06cf99bf857b6928bb3
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 35e687e36c9398fffa59133b460944a4af0904d96b16a711a2f2f215c0e114d5e281e3572da1c61396b61ac4c8461e95dea918ac598052ec799ca643cd6676c8
|
|
7
|
+
data.tar.gz: '028e35e28a4dbed03acc3a0ba39ddda5312c48210be085d2dcf50045a99c4f8dcf516d74e281d8d4176e207a6f8c22f0a0fb7796cac4d386f27114b391b60358'
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
AllCops:
|
|
2
|
+
TargetRubyVersion: 3.1
|
|
3
|
+
NewCops: enable
|
|
4
|
+
SuggestExtensions: false
|
|
5
|
+
|
|
6
|
+
Style/StringLiterals:
|
|
7
|
+
EnforcedStyle: double_quotes
|
|
8
|
+
|
|
9
|
+
Style/StringLiteralsInInterpolation:
|
|
10
|
+
EnforcedStyle: double_quotes
|
|
11
|
+
|
|
12
|
+
Style/Documentation:
|
|
13
|
+
Enabled: false
|
|
14
|
+
|
|
15
|
+
Metrics/BlockLength:
|
|
16
|
+
Exclude:
|
|
17
|
+
- "spec/**/*"
|
|
18
|
+
- "*.gemspec"
|
|
19
|
+
|
|
20
|
+
Metrics/MethodLength:
|
|
21
|
+
Exclude:
|
|
22
|
+
- "lib/figma/cli/extractors/**/*"
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# figma-cli
|
|
2
|
+
|
|
3
|
+
A Ruby gem CLI that extracts design tokens from Figma files via the REST API and writes them as compact YAML files to disk. Optimized for AI agent consumption (token-efficient alternative to Figma MCP).
|
|
4
|
+
|
|
5
|
+
## Project docs
|
|
6
|
+
|
|
7
|
+
Full design, API research, and session handoff notes are in Obsidian:
|
|
8
|
+
- Path: `/Users/thommeas/Library/Mobile Documents/iCloud~md~obsidian/Documents/My vault/Claude/figma-cli/`
|
|
9
|
+
- `design.md` — full design doc (architecture, commands, output format, gem structure, implementation steps)
|
|
10
|
+
- `figma-api-research.md` — Figma REST API endpoints, JSON structures, rate limits, gotchas
|
|
11
|
+
- `handoff.md` — session context and decisions made
|
|
12
|
+
|
|
13
|
+
## Stack
|
|
14
|
+
|
|
15
|
+
- Ruby gem (module: `Figma::Cli`)
|
|
16
|
+
- Thor (CLI framework)
|
|
17
|
+
- Faraday + faraday-retry (HTTP client with rate-limit retries)
|
|
18
|
+
- RSpec + WebMock (testing)
|
|
19
|
+
- RuboCop (linting)
|
|
20
|
+
|
|
21
|
+
## Architecture
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
lib/figma/cli/
|
|
25
|
+
version.rb # VERSION constant
|
|
26
|
+
errors.rb # Error, AuthError, NotFoundError, RateLimitError, ApiError
|
|
27
|
+
client.rb # Figma REST API client (Faraday)
|
|
28
|
+
command.rb # Thor CLI (version, extract commands)
|
|
29
|
+
extractors/
|
|
30
|
+
color_converter.rb # Figma 0-1 float → hex/rgb conversion
|
|
31
|
+
colors.rb # SOLID fill → {name, hex, rgb, opacity, style_id}
|
|
32
|
+
typography.rb # TEXT node style → {name, font_family, ...}
|
|
33
|
+
effects.rb # Shadows/blurs → {name, type, radius, ...}
|
|
34
|
+
spacing.rb # Auto-layout → {name, padding_*, item_spacing, ...}
|
|
35
|
+
parser.rb # Orchestrates URL→fetch→extract pipeline
|
|
36
|
+
writer.rb # Serializes parser output → 5 YAML files on disk
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Parser (`Figma::Cli::Parser`)
|
|
40
|
+
- `Parser.new(client:).extract(url, node_id: nil)` → `{ metadata:, colors:, typography:, effects:, spacing: }`
|
|
41
|
+
- Parses Figma URLs (`/file/` and `/design/` formats), converts `?node-id=1-2` → `1:2`
|
|
42
|
+
- Styles pipeline: fetches styles → groups by type → batch-fetches nodes → dispatches extractors
|
|
43
|
+
- Spacing pipeline: full document fetch → recursive tree walk for `layoutMode` nodes
|
|
44
|
+
- `--node-id` scopes spacing extraction to a subtree
|
|
45
|
+
|
|
46
|
+
### Writer (`Figma::Cli::Writer`)
|
|
47
|
+
- `Writer.write(data, output_dir: ".figma-cli")` → array of 5 written file paths
|
|
48
|
+
- Writes `metadata.yml` (flat), `colors.yml`, `typography.yml`, `effects.yml`, `spacing.yml` (wrapped arrays)
|
|
49
|
+
- YAML output: no `---` header, `line_width: -1`, empty categories → `colors: []`
|
|
50
|
+
- Creates output directory if missing, overwrites silently (idempotent)
|
|
51
|
+
|
|
52
|
+
### Client (`Figma::Cli::Client`)
|
|
53
|
+
- Auth: `FIGMA_TOKEN` env var or explicit `token:` param
|
|
54
|
+
- Methods: `file(key)`, `file_meta(key)`, `styles(key)`, `nodes(key, ids:)`
|
|
55
|
+
- `nodes()` auto-batches at 50 IDs per request
|
|
56
|
+
- Faraday middleware handles 429 retries (max 3)
|
|
57
|
+
|
|
58
|
+
## Commands
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
bundle exec figma-cli version
|
|
62
|
+
bundle exec figma-cli extract <figma-url> [--node-id ID] [-o DIR]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Development
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
bundle install
|
|
69
|
+
bundle exec rspec # 93 specs
|
|
70
|
+
bundle exec rubocop # 0 offenses
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Implementation Status
|
|
74
|
+
|
|
75
|
+
All 8 steps complete. 126 specs, 0 RuboCop offenses.
|
data/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# figma-cli
|
|
2
|
+
|
|
3
|
+
A Ruby gem CLI that extracts design tokens from Figma files via the REST API and writes them as compact YAML files to disk. Optimized for AI agent consumption (token-efficient alternative to Figma MCP).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
gem install figma-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or add to your Gemfile:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem "figma-cli"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
Set your Figma personal access token as an environment variable:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
export FIGMA_TOKEN=your-token-here
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
You can generate a personal access token in [Figma account settings](https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens).
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# Extract tokens from a Figma file
|
|
31
|
+
figma-cli extract <figma-url>
|
|
32
|
+
|
|
33
|
+
# Scope to a specific node/frame
|
|
34
|
+
figma-cli extract <figma-url> --node-id 123:456
|
|
35
|
+
|
|
36
|
+
# Custom output directory (default: .figma-cli/)
|
|
37
|
+
figma-cli extract <figma-url> -o design-tokens/
|
|
38
|
+
|
|
39
|
+
# Show version
|
|
40
|
+
figma-cli version
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Output
|
|
44
|
+
|
|
45
|
+
Writes 5 YAML files to `.figma-cli/` (or custom directory via `-o`):
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
.figma-cli/
|
|
49
|
+
metadata.yml
|
|
50
|
+
colors.yml
|
|
51
|
+
typography.yml
|
|
52
|
+
effects.yml
|
|
53
|
+
spacing.yml
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### metadata.yml
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
file_name: "Design System"
|
|
60
|
+
file_key: "abc123"
|
|
61
|
+
last_modified: "2026-03-02T12:00:00Z"
|
|
62
|
+
extracted_at: "2026-03-02T15:30:00Z"
|
|
63
|
+
version: "v456"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### colors.yml
|
|
67
|
+
|
|
68
|
+
```yaml
|
|
69
|
+
colors:
|
|
70
|
+
- name: "Primary/Blue"
|
|
71
|
+
hex: "#3366CC"
|
|
72
|
+
rgb: [51, 102, 204]
|
|
73
|
+
opacity: 1.0
|
|
74
|
+
style_id: "S:abc123"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### typography.yml
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
typography:
|
|
81
|
+
- name: "Heading/XL"
|
|
82
|
+
font_family: "Inter"
|
|
83
|
+
font_weight: 700
|
|
84
|
+
font_size: 32
|
|
85
|
+
line_height: 40
|
|
86
|
+
letter_spacing: 0
|
|
87
|
+
style_id: "S:ghi789"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### effects.yml
|
|
91
|
+
|
|
92
|
+
```yaml
|
|
93
|
+
effects:
|
|
94
|
+
- name: "Shadow/Medium"
|
|
95
|
+
type: "DROP_SHADOW"
|
|
96
|
+
offset_x: 0
|
|
97
|
+
offset_y: 2
|
|
98
|
+
radius: 8
|
|
99
|
+
spread: 0
|
|
100
|
+
color_hex: "#0000001A"
|
|
101
|
+
style_id: "S:jkl012"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### spacing.yml
|
|
105
|
+
|
|
106
|
+
```yaml
|
|
107
|
+
spacing:
|
|
108
|
+
- name: "Card/Container"
|
|
109
|
+
padding_top: 24
|
|
110
|
+
padding_right: 24
|
|
111
|
+
padding_bottom: 24
|
|
112
|
+
padding_left: 24
|
|
113
|
+
item_spacing: 16
|
|
114
|
+
layout_mode: "VERTICAL"
|
|
115
|
+
node_id: "123:456"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Re-running the same command skips extraction if the Figma file version hasn't changed.
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
git clone https://github.com/Wethre/figma-cli.git
|
|
124
|
+
cd figma-cli
|
|
125
|
+
bundle install
|
|
126
|
+
bundle exec rspec # run tests
|
|
127
|
+
bundle exec rubocop # lint
|
|
128
|
+
```
|
data/Rakefile
ADDED
data/exe/figma-cli
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/retry"
|
|
5
|
+
|
|
6
|
+
module Figma
|
|
7
|
+
module Cli
|
|
8
|
+
class Client
|
|
9
|
+
BASE_URL = "https://api.figma.com"
|
|
10
|
+
|
|
11
|
+
BATCH_SIZE = 50
|
|
12
|
+
|
|
13
|
+
def initialize(token: ENV.fetch("FIGMA_TOKEN", nil))
|
|
14
|
+
raise AuthError, "Missing FIGMA_TOKEN. Set it via: export FIGMA_TOKEN=your-token" if token.nil? || token.empty?
|
|
15
|
+
|
|
16
|
+
@token = token
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def file(file_key)
|
|
20
|
+
get("/v1/files/#{file_key}")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def file_meta(file_key)
|
|
24
|
+
get("/v1/files/#{file_key}", depth: 1)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def styles(file_key)
|
|
28
|
+
get("/v1/files/#{file_key}/styles")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def nodes(file_key, ids:)
|
|
32
|
+
ids.each_slice(BATCH_SIZE).with_object({}) do |batch, merged|
|
|
33
|
+
response = get("/v1/files/#{file_key}/nodes", ids: batch.join(","))
|
|
34
|
+
merged.merge!(response["nodes"])
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def connection
|
|
41
|
+
@connection ||= Faraday.new(url: BASE_URL) do |f|
|
|
42
|
+
f.headers["X-Figma-Token"] = @token
|
|
43
|
+
f.request :retry, max: 3, interval: 1, retry_statuses: [429]
|
|
44
|
+
f.response :json
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def get(path, params = {})
|
|
49
|
+
response = connection.get(path, params)
|
|
50
|
+
handle_response(response)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def handle_response(response)
|
|
54
|
+
case response.status
|
|
55
|
+
when 200 then response.body
|
|
56
|
+
when 403 then raise AuthError, "Access denied (403). Check your FIGMA_TOKEN permissions."
|
|
57
|
+
when 404 then raise NotFoundError, "Resource not found (404). Check the file key or node IDs."
|
|
58
|
+
when 429 then raise RateLimitError, "Rate limited (429). Retries exhausted."
|
|
59
|
+
else raise ApiError, "Figma API error (#{response.status})"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Figma
|
|
7
|
+
module Cli
|
|
8
|
+
class Command < Thor
|
|
9
|
+
desc "version", "Print figma-cli version"
|
|
10
|
+
def version
|
|
11
|
+
puts "figma-cli #{VERSION}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
desc "extract URL", "Extract design tokens from a Figma file"
|
|
15
|
+
option :node_id, type: :string, desc: "Figma node ID to scope extraction"
|
|
16
|
+
option :output, aliases: "-o", type: :string, default: ".figma-cli", desc: "Output directory"
|
|
17
|
+
def extract(url) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
18
|
+
client = Client.new
|
|
19
|
+
parser = Parser.new(client: client)
|
|
20
|
+
|
|
21
|
+
# Check cache: skip if local version matches remote
|
|
22
|
+
version_info = parser.file_version(url)
|
|
23
|
+
metadata_path = File.join(options[:output], "metadata.yml")
|
|
24
|
+
if File.exist?(metadata_path)
|
|
25
|
+
local = YAML.safe_load_file(metadata_path)
|
|
26
|
+
if local && local["version"] == version_info[:version]
|
|
27
|
+
warn "Up to date (version #{version_info[:version]}), skipping."
|
|
28
|
+
return
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
data = parser.extract(url, node_id: options[:node_id])
|
|
33
|
+
paths = Writer.write(data, output_dir: options[:output])
|
|
34
|
+
warn "Wrote #{paths.size} files to #{options[:output]}/"
|
|
35
|
+
paths.each { |p| warn " #{p}" }
|
|
36
|
+
rescue Error => e
|
|
37
|
+
warn "Error: #{e.message}"
|
|
38
|
+
exit 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.exit_on_failure?
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Figma
|
|
4
|
+
module Cli
|
|
5
|
+
module Extractors
|
|
6
|
+
module ColorConverter
|
|
7
|
+
def self.to_hex(color)
|
|
8
|
+
r, g, b = to_rgb(color)
|
|
9
|
+
format("#%<r>02X%<g>02X%<b>02X", r: r, g: g, b: b)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.to_hex_alpha(color)
|
|
13
|
+
r, g, b = to_rgb(color)
|
|
14
|
+
a = (color["a"] * 255).round
|
|
15
|
+
format("#%<r>02X%<g>02X%<b>02X%<a>02X", r: r, g: g, b: b, a: a)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.to_rgb(color)
|
|
19
|
+
[(color["r"] * 255).round, (color["g"] * 255).round, (color["b"] * 255).round]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Figma
|
|
4
|
+
module Cli
|
|
5
|
+
module Extractors
|
|
6
|
+
module Colors
|
|
7
|
+
def self.extract(document, style_name:, style_id:)
|
|
8
|
+
fills = document.fetch("fills", [])
|
|
9
|
+
fills.select { |f| f["type"] == "SOLID" }.map do |fill|
|
|
10
|
+
{
|
|
11
|
+
name: style_name,
|
|
12
|
+
hex: ColorConverter.to_hex(fill["color"]),
|
|
13
|
+
rgb: ColorConverter.to_rgb(fill["color"]),
|
|
14
|
+
opacity: fill.fetch("opacity", 1.0).to_f,
|
|
15
|
+
style_id: style_id
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Figma
|
|
4
|
+
module Cli
|
|
5
|
+
module Extractors
|
|
6
|
+
module Effects
|
|
7
|
+
SHADOW_TYPES = %w[DROP_SHADOW INNER_SHADOW].freeze
|
|
8
|
+
BLUR_TYPES = %w[LAYER_BLUR BACKGROUND_BLUR].freeze
|
|
9
|
+
|
|
10
|
+
def self.extract(document, style_name:, style_id:)
|
|
11
|
+
effects = document.fetch("effects", [])
|
|
12
|
+
effects.select { |e| e["visible"] }.map do |effect|
|
|
13
|
+
if SHADOW_TYPES.include?(effect["type"])
|
|
14
|
+
extract_shadow(effect, style_name: style_name, style_id: style_id)
|
|
15
|
+
elsif BLUR_TYPES.include?(effect["type"])
|
|
16
|
+
extract_blur(effect, style_name: style_name, style_id: style_id)
|
|
17
|
+
end
|
|
18
|
+
end.compact
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.extract_shadow(effect, style_name:, style_id:)
|
|
22
|
+
offset = effect.fetch("offset", {})
|
|
23
|
+
{
|
|
24
|
+
name: style_name,
|
|
25
|
+
type: effect["type"],
|
|
26
|
+
offset_x: offset.fetch("x", 0),
|
|
27
|
+
offset_y: offset.fetch("y", 0),
|
|
28
|
+
radius: effect.fetch("radius", 0),
|
|
29
|
+
spread: effect.fetch("spread", 0),
|
|
30
|
+
color_hex: ColorConverter.to_hex_alpha(effect["color"]),
|
|
31
|
+
style_id: style_id
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
private_class_method :extract_shadow
|
|
35
|
+
|
|
36
|
+
def self.extract_blur(effect, style_name:, style_id:)
|
|
37
|
+
{
|
|
38
|
+
name: style_name,
|
|
39
|
+
type: effect["type"],
|
|
40
|
+
radius: effect.fetch("radius", 0),
|
|
41
|
+
style_id: style_id
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
private_class_method :extract_blur
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Figma
|
|
4
|
+
module Cli
|
|
5
|
+
module Extractors
|
|
6
|
+
module Spacing
|
|
7
|
+
def self.extract(document, node_name:, node_id:)
|
|
8
|
+
return nil unless document["layoutMode"]
|
|
9
|
+
|
|
10
|
+
{
|
|
11
|
+
name: node_name,
|
|
12
|
+
padding_top: document.fetch("paddingTop", 0),
|
|
13
|
+
padding_right: document.fetch("paddingRight", 0),
|
|
14
|
+
padding_bottom: document.fetch("paddingBottom", 0),
|
|
15
|
+
padding_left: document.fetch("paddingLeft", 0),
|
|
16
|
+
item_spacing: document.fetch("itemSpacing", 0),
|
|
17
|
+
layout_mode: document["layoutMode"],
|
|
18
|
+
node_id: node_id
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Figma
|
|
4
|
+
module Cli
|
|
5
|
+
module Extractors
|
|
6
|
+
module Typography
|
|
7
|
+
def self.extract(document, style_name:, style_id:)
|
|
8
|
+
style = document["style"]
|
|
9
|
+
return nil unless style
|
|
10
|
+
|
|
11
|
+
{
|
|
12
|
+
name: style_name,
|
|
13
|
+
font_family: style["fontFamily"],
|
|
14
|
+
font_weight: style.fetch("fontWeight", 0),
|
|
15
|
+
font_size: style.fetch("fontSize", 0),
|
|
16
|
+
line_height: style.fetch("lineHeightPx", 0),
|
|
17
|
+
letter_spacing: style.fetch("letterSpacing", 0),
|
|
18
|
+
style_id: style_id
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Figma
|
|
7
|
+
module Cli
|
|
8
|
+
class Parser
|
|
9
|
+
URL_PATTERN = %r{\Ahttps?://[^/]+/(file|design)/([^/]+)}
|
|
10
|
+
STYLE_TYPES = %w[FILL TEXT EFFECT].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(client:)
|
|
13
|
+
@client = client
|
|
14
|
+
@cached_meta = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def extract(url, node_id: nil)
|
|
18
|
+
parsed = parse_url(url)
|
|
19
|
+
file_key = parsed[:file_key]
|
|
20
|
+
node_id ||= parsed[:node_id]
|
|
21
|
+
metadata = fetch_metadata(file_key)
|
|
22
|
+
styles = extract_styles(file_key)
|
|
23
|
+
{ metadata: metadata, **styles, spacing: extract_spacing(file_key, node_id: node_id) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def file_version(url)
|
|
27
|
+
parsed = parse_url(url)
|
|
28
|
+
file_key = parsed[:file_key]
|
|
29
|
+
@cached_meta = @client.file_meta(file_key)
|
|
30
|
+
{ file_key: file_key, version: @cached_meta["version"] }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def parse_url(url)
|
|
36
|
+
match = URL_PATTERN.match(url)
|
|
37
|
+
raise Error, "Invalid Figma URL: #{url}" unless match
|
|
38
|
+
|
|
39
|
+
{ file_key: match[2], node_id: parse_node_id(url) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def parse_node_id(url)
|
|
43
|
+
query = URI.parse(url).query
|
|
44
|
+
return nil unless query
|
|
45
|
+
|
|
46
|
+
URI.decode_www_form(query).to_h["node-id"]&.tr("-", ":")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def fetch_metadata(file_key)
|
|
50
|
+
meta = @cached_meta || @client.file_meta(file_key)
|
|
51
|
+
@cached_meta = nil
|
|
52
|
+
{ file_name: meta["name"], file_key: file_key, last_modified: meta["lastModified"],
|
|
53
|
+
extracted_at: Time.now.utc.iso8601, version: meta["version"] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def extract_styles(file_key)
|
|
57
|
+
all = @client.styles(file_key).dig("meta", "styles") || []
|
|
58
|
+
grouped = all.group_by { |s| s["style_type"] }.slice(*STYLE_TYPES)
|
|
59
|
+
return empty_styles if grouped.empty?
|
|
60
|
+
|
|
61
|
+
known = grouped.values.flatten
|
|
62
|
+
nodes = fetch_style_nodes(file_key, known)
|
|
63
|
+
build_style_results(grouped, nodes)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def fetch_style_nodes(file_key, styles)
|
|
67
|
+
@client.nodes(file_key, ids: styles.map { |s| s["node_id"] }.uniq)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def build_style_results(grouped, nodes)
|
|
71
|
+
{ colors: run_extractor(grouped["FILL"], nodes),
|
|
72
|
+
typography: run_extractor(grouped["TEXT"], nodes),
|
|
73
|
+
effects: run_extractor(grouped["EFFECT"], nodes) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def run_extractor(styles, nodes)
|
|
77
|
+
return [] unless styles
|
|
78
|
+
|
|
79
|
+
styles.flat_map do |s|
|
|
80
|
+
doc = nodes.dig(s["node_id"], "document")
|
|
81
|
+
doc ? dispatch(s, doc) : nil
|
|
82
|
+
end.compact
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def dispatch(style, doc)
|
|
86
|
+
n, k = style.values_at("name", "key")
|
|
87
|
+
case style["style_type"]
|
|
88
|
+
when "FILL" then Extractors::Colors.extract(doc, style_name: n, style_id: k)
|
|
89
|
+
when "TEXT" then Extractors::Typography.extract(doc, style_name: n, style_id: k)
|
|
90
|
+
when "EFFECT" then Extractors::Effects.extract(doc, style_name: n, style_id: k)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def extract_spacing(file_key, node_id: nil)
|
|
95
|
+
root = @client.file(file_key)["document"]
|
|
96
|
+
tree = node_id ? find_subtree(root, node_id) : root
|
|
97
|
+
tree ? collect_spacing(tree) : []
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def find_subtree(node, target_id)
|
|
101
|
+
return node if node["id"] == target_id
|
|
102
|
+
|
|
103
|
+
node.fetch("children", []).each do |child|
|
|
104
|
+
found = find_subtree(child, target_id)
|
|
105
|
+
return found if found
|
|
106
|
+
end
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def collect_spacing(node)
|
|
111
|
+
results = []
|
|
112
|
+
token = Extractors::Spacing.extract(node, node_name: node["name"], node_id: node["id"])
|
|
113
|
+
results << token if token
|
|
114
|
+
node.fetch("children", []).each { |child| results.concat(collect_spacing(child)) }
|
|
115
|
+
results
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def empty_styles
|
|
119
|
+
{ colors: [], typography: [], effects: [] }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Figma
|
|
7
|
+
module Cli
|
|
8
|
+
module Writer
|
|
9
|
+
CATEGORIES = %i[colors typography effects spacing].freeze
|
|
10
|
+
|
|
11
|
+
def self.write(data, output_dir: ".figma-cli")
|
|
12
|
+
FileUtils.mkdir_p(output_dir)
|
|
13
|
+
paths = []
|
|
14
|
+
|
|
15
|
+
# metadata (flat)
|
|
16
|
+
paths << write_file(File.join(output_dir, "metadata.yml"), format_yaml(stringify_keys(data[:metadata])))
|
|
17
|
+
|
|
18
|
+
# token categories (wrapped)
|
|
19
|
+
CATEGORIES.each do |cat|
|
|
20
|
+
content = { cat.to_s => data[cat].map { |h| stringify_keys(h) } }
|
|
21
|
+
paths << write_file(File.join(output_dir, "#{cat}.yml"), format_yaml(content))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
paths
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.write_file(path, content)
|
|
28
|
+
File.write(path, content)
|
|
29
|
+
path
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.format_yaml(data)
|
|
33
|
+
YAML.dump(data, line_width: -1).sub(/\A---\n/, "")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.stringify_keys(hash)
|
|
37
|
+
hash.transform_keys(&:to_s)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private_class_method :write_file, :format_yaml, :stringify_keys
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/figma/cli.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cli/version"
|
|
4
|
+
require_relative "cli/errors"
|
|
5
|
+
require_relative "cli/client"
|
|
6
|
+
require_relative "cli/parser"
|
|
7
|
+
require_relative "cli/writer"
|
|
8
|
+
require_relative "cli/command"
|
|
9
|
+
require_relative "cli/extractors/color_converter"
|
|
10
|
+
require_relative "cli/extractors/colors"
|
|
11
|
+
require_relative "cli/extractors/typography"
|
|
12
|
+
require_relative "cli/extractors/effects"
|
|
13
|
+
require_relative "cli/extractors/spacing"
|
|
14
|
+
|
|
15
|
+
module Figma
|
|
16
|
+
module Cli
|
|
17
|
+
end
|
|
18
|
+
end
|
data/sig/figma/cli.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: figma-cli
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Thom Meas
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-12 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: faraday
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '2.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: faraday-retry
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '2.0'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '2.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: thor
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '1.0'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '1.0'
|
|
55
|
+
description: A CLI tool that extracts design tokens (colors, typography, spacing,
|
|
56
|
+
effects) from Figma files via the REST API and writes them as compact YAML files
|
|
57
|
+
to disk. Optimized for AI agent consumption.
|
|
58
|
+
email:
|
|
59
|
+
- thom.meas@gmail.com
|
|
60
|
+
executables:
|
|
61
|
+
- figma-cli
|
|
62
|
+
extensions: []
|
|
63
|
+
extra_rdoc_files: []
|
|
64
|
+
files:
|
|
65
|
+
- ".rspec"
|
|
66
|
+
- ".rubocop.yml"
|
|
67
|
+
- CLAUDE.md
|
|
68
|
+
- README.md
|
|
69
|
+
- Rakefile
|
|
70
|
+
- exe/figma-cli
|
|
71
|
+
- lib/figma/cli.rb
|
|
72
|
+
- lib/figma/cli/client.rb
|
|
73
|
+
- lib/figma/cli/command.rb
|
|
74
|
+
- lib/figma/cli/errors.rb
|
|
75
|
+
- lib/figma/cli/extractors/color_converter.rb
|
|
76
|
+
- lib/figma/cli/extractors/colors.rb
|
|
77
|
+
- lib/figma/cli/extractors/effects.rb
|
|
78
|
+
- lib/figma/cli/extractors/spacing.rb
|
|
79
|
+
- lib/figma/cli/extractors/typography.rb
|
|
80
|
+
- lib/figma/cli/parser.rb
|
|
81
|
+
- lib/figma/cli/version.rb
|
|
82
|
+
- lib/figma/cli/writer.rb
|
|
83
|
+
- sig/figma/cli.rbs
|
|
84
|
+
homepage: https://github.com/Wethre/figma-cli
|
|
85
|
+
licenses:
|
|
86
|
+
- MIT
|
|
87
|
+
metadata:
|
|
88
|
+
homepage_uri: https://github.com/Wethre/figma-cli
|
|
89
|
+
source_code_uri: https://github.com/Wethre/figma-cli
|
|
90
|
+
changelog_uri: https://github.com/Wethre/figma-cli/blob/main/CHANGELOG.md
|
|
91
|
+
rubygems_mfa_required: 'true'
|
|
92
|
+
post_install_message:
|
|
93
|
+
rdoc_options: []
|
|
94
|
+
require_paths:
|
|
95
|
+
- lib
|
|
96
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - ">="
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: 3.1.0
|
|
101
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
102
|
+
requirements:
|
|
103
|
+
- - ">="
|
|
104
|
+
- !ruby/object:Gem::Version
|
|
105
|
+
version: '0'
|
|
106
|
+
requirements: []
|
|
107
|
+
rubygems_version: 3.5.3
|
|
108
|
+
signing_key:
|
|
109
|
+
specification_version: 4
|
|
110
|
+
summary: Extract Figma design tokens to YAML files on disk
|
|
111
|
+
test_files: []
|