figma-to-html-pixel-perfect 1.0.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.
- package/LICENSE +21 -0
- package/README.md +217 -0
- package/SKILL.md +1855 -0
- package/bin/install.js +42 -0
- package/package.json +23 -0
- package/references/accessibility-checklist.md +68 -0
- package/references/animation-guidelines.md +113 -0
- package/references/visual-review-checklist.md +121 -0
- package/scripts/figma_discover.py +218 -0
- package/scripts/figma_fonts.py +90 -0
- package/scripts/figma_icons.py +391 -0
- package/scripts/figma_lint.py +246 -0
- package/scripts/figma_pull.py +153 -0
- package/scripts/figma_report.py +1320 -0
- package/scripts/figma_spec.py +93 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fetch node JSON + one render per section from the Figma REST API.
|
|
3
|
+
|
|
4
|
+
Implements §6.5.1 (preflight) and §6.5.2 (spend the render budget first).
|
|
5
|
+
|
|
6
|
+
Deliberately does NOT bulk-download image fills: that burns the shared quota the
|
|
7
|
+
renders need. Fetch assets afterwards, only for the refs you actually use
|
|
8
|
+
(see `download_refs`).
|
|
9
|
+
|
|
10
|
+
Token: env FIGMA_TOKEN, else ~/.figma_token (scope: File content: Read)
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
python3 figma_pull.py <fileKey> <nodeId>[,<nodeId>...] [outdir]
|
|
14
|
+
python3 figma_pull.py <fileKey> --hover <destId>[,<destId>...] [outdir]
|
|
15
|
+
|
|
16
|
+
`--hover` fetches the variant nodes that `interactions[].destinationId` points at (find them
|
|
17
|
+
with figma_discover.py) and renders each one. Those renders are the reference for the hover
|
|
18
|
+
state; nothing else in this toolkit can tell you what a hover is supposed to look like.
|
|
19
|
+
|
|
20
|
+
Writes:
|
|
21
|
+
<outdir>/nodes/<nodeId>.json full node tree
|
|
22
|
+
<outdir>/renders/<nodeId>.png one render per node <-- LOOK AT THESE
|
|
23
|
+
"""
|
|
24
|
+
import json, os, sys, time, pathlib, urllib.parse, urllib.request
|
|
25
|
+
|
|
26
|
+
API = "https://api.figma.com/v1"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def token():
|
|
30
|
+
if os.environ.get("FIGMA_TOKEN"):
|
|
31
|
+
return os.environ["FIGMA_TOKEN"].strip()
|
|
32
|
+
p = pathlib.Path.home() / ".figma_token"
|
|
33
|
+
if p.exists():
|
|
34
|
+
return p.read_text().strip()
|
|
35
|
+
sys.exit("No token. figma.com > Settings > Security > Personal access tokens\n"
|
|
36
|
+
"then: echo 'TOKEN' > ~/.figma_token && chmod 600 ~/.figma_token")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get(path, tok):
|
|
40
|
+
req = urllib.request.Request(API + path, headers={"X-Figma-Token": tok})
|
|
41
|
+
try:
|
|
42
|
+
with urllib.request.urlopen(req, timeout=90) as r:
|
|
43
|
+
return json.load(r)
|
|
44
|
+
except urllib.error.HTTPError as e:
|
|
45
|
+
body = e.read(300).decode(errors="replace")
|
|
46
|
+
if e.code == 429:
|
|
47
|
+
ra = e.headers.get("Retry-After")
|
|
48
|
+
hrs = f" ({int(ra)/3600:.1f} h)" if ra and ra.isdigit() else ""
|
|
49
|
+
sys.exit(f"429 rate limited. Retry-After: {ra}{hrs}\n"
|
|
50
|
+
f"Do not plan around a quick reset. {body}")
|
|
51
|
+
if e.code == 403 and "not exportable" in body:
|
|
52
|
+
sys.exit("403 File not exportable — the owner disabled export/copy/share.\n"
|
|
53
|
+
"Only the owner (or an editor) can lift this. Nothing else will work.")
|
|
54
|
+
sys.exit(f"HTTP {e.code}: {body}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main():
|
|
58
|
+
if len(sys.argv) < 3:
|
|
59
|
+
sys.exit(__doc__)
|
|
60
|
+
argv = sys.argv[1:]
|
|
61
|
+
hover = False
|
|
62
|
+
if "--hover" in argv:
|
|
63
|
+
hover = True
|
|
64
|
+
argv.remove("--hover")
|
|
65
|
+
force = "--force" in argv
|
|
66
|
+
if force:
|
|
67
|
+
argv.remove("--force")
|
|
68
|
+
key, ids = argv[0], [i.strip().replace("-", ":") for i in argv[1].split(",")]
|
|
69
|
+
out = pathlib.Path(argv[2] if len(argv) > 2 else "figma")
|
|
70
|
+
tok = token()
|
|
71
|
+
# CACHE-FIRST. Every REST endpoint (including /v1/files) sits under a plan-based
|
|
72
|
+
# monthly quota and 429s with Retry-After up to hours/days. A node that is already
|
|
73
|
+
# on disk is NEVER re-fetched — the cache is the workspace, the API is only for
|
|
74
|
+
# what is missing. Use --force to deliberately refresh.
|
|
75
|
+
if not force:
|
|
76
|
+
cached = [i for i in ids if (out / "nodes" / f"{i.replace(':','-')}.json").exists()]
|
|
77
|
+
if cached:
|
|
78
|
+
print(f"cache-first: {len(cached)}/{len(ids)} node(s) already in {out}/nodes — skipped "
|
|
79
|
+
f"(--force to re-fetch)")
|
|
80
|
+
ids = [i for i in ids if i not in cached]
|
|
81
|
+
if not ids:
|
|
82
|
+
return
|
|
83
|
+
if hover:
|
|
84
|
+
(out / "hover").mkdir(parents=True, exist_ok=True)
|
|
85
|
+
nodes = get(f"/files/{key}/nodes?ids={urllib.parse.quote(','.join(ids))}", tok)
|
|
86
|
+
for nid in ids:
|
|
87
|
+
n = nodes["nodes"].get(nid)
|
|
88
|
+
if n:
|
|
89
|
+
(out / "hover" / f"{nid.replace(':','-')}.json").write_text(
|
|
90
|
+
json.dumps(n, indent=2, ensure_ascii=False))
|
|
91
|
+
for nid in ids:
|
|
92
|
+
r = get(f"/images/{key}?ids={urllib.parse.quote(nid)}&format=png&scale=2", tok)
|
|
93
|
+
url = (r.get("images") or {}).get(nid)
|
|
94
|
+
if not url:
|
|
95
|
+
print(f" !! no render for hover variant {nid}")
|
|
96
|
+
continue
|
|
97
|
+
with urllib.request.urlopen(url, timeout=240) as im:
|
|
98
|
+
(out / "hover" / f"{nid.replace(':','-')}.png").write_bytes(im.read())
|
|
99
|
+
print(f" hover variant {nid}")
|
|
100
|
+
time.sleep(1)
|
|
101
|
+
print("\nLook at these. Then make your :hover match them (§9.0).")
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
# §6.5.1 preflight — fail fast with the real reason
|
|
105
|
+
get(f"/files/{key}?depth=1", tok)
|
|
106
|
+
print("preflight ok: file is readable and exportable")
|
|
107
|
+
|
|
108
|
+
(out / "nodes").mkdir(parents=True, exist_ok=True)
|
|
109
|
+
(out / "renders").mkdir(parents=True, exist_ok=True)
|
|
110
|
+
|
|
111
|
+
# 1) node trees (single call)
|
|
112
|
+
nodes = get(f"/files/{key}/nodes?ids={urllib.parse.quote(','.join(ids))}", tok)
|
|
113
|
+
for nid in ids:
|
|
114
|
+
n = nodes["nodes"].get(nid)
|
|
115
|
+
if n:
|
|
116
|
+
(out / "nodes" / f"{nid.replace(':','-')}.json").write_text(
|
|
117
|
+
json.dumps(n, indent=2, ensure_ascii=False))
|
|
118
|
+
print(f"saved {len(ids)} node trees -> {out}/nodes/")
|
|
119
|
+
|
|
120
|
+
# 2) ONE RENDER PER SECTION — the scarce budget. Fail loudly, never silently.
|
|
121
|
+
failed = []
|
|
122
|
+
for nid in ids:
|
|
123
|
+
r = get(f"/images/{key}?ids={urllib.parse.quote(nid)}&format=png&scale=1", tok)
|
|
124
|
+
url = (r.get("images") or {}).get(nid)
|
|
125
|
+
if not url:
|
|
126
|
+
failed.append(nid)
|
|
127
|
+
print(f" !! NO RENDER for {nid} (err={r.get('err')})", flush=True)
|
|
128
|
+
continue
|
|
129
|
+
with urllib.request.urlopen(url, timeout=240) as im:
|
|
130
|
+
(out / "renders" / f"{nid.replace(':','-')}.png").write_bytes(im.read())
|
|
131
|
+
print(f" rendered {nid}", flush=True)
|
|
132
|
+
time.sleep(1)
|
|
133
|
+
|
|
134
|
+
if failed:
|
|
135
|
+
sys.exit(f"\nBLOCKED: {len(failed)} section(s) have no reference render: {failed}\n"
|
|
136
|
+
"Per §6.5.0 these must NOT be implemented from geometry alone.\n"
|
|
137
|
+
"Report them to the user and ask for screenshots.")
|
|
138
|
+
print("\nAll sections rendered. Now OPEN EVERY RENDER before writing any code.")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def download_refs(key, refs, dest="assets"):
|
|
142
|
+
"""Fetch only the imageRefs you actually use. Call after the renders exist."""
|
|
143
|
+
tok = token()
|
|
144
|
+
m = get(f"/files/{key}/images", tok)["meta"]["images"]
|
|
145
|
+
pathlib.Path(dest).mkdir(parents=True, exist_ok=True)
|
|
146
|
+
for name, ref in refs.items():
|
|
147
|
+
with urllib.request.urlopen(m[ref], timeout=240) as r:
|
|
148
|
+
pathlib.Path(f"{dest}/{name}.png").write_bytes(r.read())
|
|
149
|
+
print("saved", name)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
main()
|