@mmerterden/multi-agent-pipeline 20.5.0 → 20.7.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +10 -0
  3. package/docs/facts.json +5 -4
  4. package/install/_dev-only-files.mjs +1 -0
  5. package/manifest.json +84 -82
  6. package/package.json +4 -3
  7. package/pipeline/lib/confusables.json +100 -0
  8. package/pipeline/lib/normalize-text.mjs +48 -0
  9. package/pipeline/lib/outbound-gate.mjs +10 -1
  10. package/pipeline/lib/redact.mjs +4 -1
  11. package/pipeline/lib/vercel-deploy.sh +5 -7
  12. package/pipeline/multi-agent-refs/features/design-conformance.md +62 -64
  13. package/pipeline/scripts/_notices.mjs +11 -0
  14. package/pipeline/scripts/agent-guard.py +30 -1
  15. package/pipeline/scripts/gen-skills-index.mjs +1 -1
  16. package/pipeline/scripts/pre-commit-check.sh +59 -16
  17. package/pipeline/scripts/pre-push-check.sh +3 -3
  18. package/pipeline/scripts/run-ui-tests.sh +5 -5
  19. package/pipeline/scripts/website-deploy-commit.sh +3 -2
  20. package/pipeline/skills/.skill-manifest.json +39 -39
  21. package/pipeline/skills/shared/README.md +1 -1
  22. package/pipeline/skills/shared/external/agent-introspection-debugging/SKILL.md +1 -0
  23. package/pipeline/skills/shared/external/android-architecture/SKILL.md +2 -0
  24. package/pipeline/skills/shared/external/android-performance/SKILL.md +2 -0
  25. package/pipeline/skills/shared/external/android-security/SKILL.md +2 -0
  26. package/pipeline/skills/shared/external/backlog/BACKLOG.md +1 -1
  27. package/pipeline/skills/shared/external/backlog/SKILL.md +56 -33
  28. package/pipeline/skills/shared/external/ci-cd-pipelines/SKILL.md +1 -0
  29. package/pipeline/skills/shared/external/compose-components/SKILL.md +2 -0
  30. package/pipeline/skills/shared/external/compose-navigation/SKILL.md +3 -2
  31. package/pipeline/skills/shared/external/compose-testing/SKILL.md +2 -0
  32. package/pipeline/skills/shared/external/council/SKILL.md +1 -0
  33. package/pipeline/skills/shared/external/css-modern/SKILL.md +1 -0
  34. package/pipeline/skills/shared/external/database-patterns/SKILL.md +1 -0
  35. package/pipeline/skills/shared/external/evidence-github/SKILL.md +2 -0
  36. package/pipeline/skills/shared/external/evidence-registry/SKILL.md +2 -0
  37. package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +2 -0
  38. package/pipeline/skills/shared/external/html-semantic/SKILL.md +1 -0
  39. package/pipeline/skills/shared/external/humanizer/SKILL.md +1 -0
  40. package/pipeline/skills/shared/external/ios-coding-standard/SKILL.md +1 -0
  41. package/pipeline/skills/shared/external/ios-module-structure/SKILL.md +1 -0
  42. package/pipeline/skills/shared/external/ios-security/SKILL.md +2 -0
  43. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +91 -283
  44. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +119 -151
  45. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +60 -90
  46. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +119 -156
  47. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +726 -787
  48. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +253 -288
  49. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +243 -304
  50. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +88 -104
  51. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +181 -235
  52. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +198 -263
  53. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +461 -466
  54. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +145 -151
  55. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +123 -141
  56. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +146 -157
  57. package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +22 -19
  58. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +156 -140
  59. package/pipeline/skills/shared/external/nextjs-app-router/SKILL.md +1 -0
  60. package/pipeline/skills/shared/external/play-store-review/SKILL.md +2 -0
  61. package/pipeline/skills/shared/external/python-patterns/SKILL.md +1 -0
  62. package/pipeline/skills/shared/external/react-best-practices/SKILL.md +1 -0
  63. package/pipeline/skills/shared/external/rest-api-design/SKILL.md +1 -0
  64. package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +2 -0
  65. package/pipeline/skills/shared/external/room-database/SKILL.md +2 -0
  66. package/pipeline/skills/shared/external/search-first/SKILL.md +1 -0
  67. package/pipeline/skills/shared/external/signal-community/SKILL.md +2 -0
  68. package/pipeline/skills/shared/external/skill-creator/SKILL.md +80 -41
  69. package/pipeline/skills/shared/external/skill-creator/audit.md +63 -59
  70. package/pipeline/skills/shared/external/skill-creator/checklist.md +28 -20
  71. package/pipeline/skills/shared/external/skill-creator/examples.md +40 -40
  72. package/pipeline/skills/shared/external/skill-creator/label-check.md +48 -36
  73. package/pipeline/skills/shared/external/skill-creator/scripts/audit-panel.js +91 -100
  74. package/pipeline/skills/shared/external/skill-creator/template.md +51 -39
  75. package/pipeline/skills/shared/external/tailwind-css/SKILL.md +1 -0
  76. package/pipeline/skills/shared/external/testing-backend/SKILL.md +1 -0
  77. package/pipeline/skills/shared/external/typescript-patterns/SKILL.md +1 -0
  78. package/pipeline/skills/shared/external/vue-composition/SKILL.md +1 -0
  79. package/pipeline/skills/shared/external/web-accessibility/SKILL.md +1 -0
  80. package/pipeline/skills/shared/external/web-performance/SKILL.md +1 -0
  81. package/pipeline/skills/shared/external/web-testing/SKILL.md +1 -0
@@ -1,528 +1,523 @@
1
1
  #!/usr/bin/env python3
2
- """Render a "which key ↔ which UI element" overlay image for one screen.
3
-
4
- Figma's own image render does NOT include the Dev Mode annotation overlay, so this
5
- RECONSTRUCTS it deterministically: the screen image (REST render or an MCP-supplied
6
- data URL) on the right, one card per keyable text element on the left, each card
7
- tying the **localization key** to the element and showing the content team's
8
- **CMS TR/EN** copy where annotated. No AI image generation - pure geometry + HTML +
9
- headless Chrome (with an `.html` fallback when no Chrome is present).
10
-
11
- Two input modes (mirrors fetch-annotations.py's REST/MCP split):
12
- * REST (default) - needs a Figma token (--token / env / keychain FIGMA_ACCESS_TOKEN):
13
- geometry `GET /v1/files/<key>/nodes?ids=<id>` → absoluteBoundingBox
14
- image `GET /v1/images/<key>?ids=<id>&format=png&scale=<n>` → PNG url → data URL
15
- * --spec <file> (MCP / no network): { fileKey, pages:[{nodeId,name,image:<dataUrl>,
16
- frame:{w,h}, nodes:[{characters,tr,en,x,y,w,h}] }] } - the orchestrator builds
17
- this from get_screenshot + use_figma so no REST calls happen.
18
-
19
- Cards are keyed off the run's **mapping JSON** (`--mapping`): each text node is matched to
20
- a mapping row (by `cmsNodeId`, else by normalized value) to label the card with its
21
- `newKey`. A node carrying a Final UX Writing annotation → pink "İçerik (CMS)" card with
22
- TR/EN; a keyable node without one → gray "Öneri bekleniyor" card with the on-screen text.
23
- Non-keyable text (no key, no annotation) is dropped.
24
-
25
- **No annotations needed.** When value-matching is unreliable (design wording ≠ resolved
26
- values, or the frame mixes in background content), anchor explicitly: set each row's
27
- `cmsNodeId` to a synthetic id and feed a `--spec` whose nodes carry the same `id` + the
28
- box read off the clean screenshot - `cmsNodeId` matching is exact, so the overlay is
29
- faithful with zero annotations. (`design-export/.../tree.json` is component-instance level
30
- - use its root `nodeId` for the REST frame, not for per-label boxes.)
31
-
32
- Output: `<out>/<slug>.overlay.png` (or `<slug>.overlay.html` fallback) + a sibling
33
- `<slug>.overlay.manifest.json` (`page → file → {fileKey,nodeId}`) so the publish step
34
- can find the image to attach. Read-only against every source.
2
+ """Draw the overlay that ties each localization key to its place on one screen.
3
+
4
+ The screen image sits in the middle; every keyed element gets a numbered card
5
+ at the side and a dashed line leading to it. A card's number equals the index,
6
+ counted from 1, of its matching entry in mapping.rows. Boxes are read from
7
+ Figma over REST, or taken from a --spec file, and headless Chrome turns the
8
+ HTML page into a PNG. The same input always gives the same picture; nothing is
9
+ generated by a model.
35
10
  """
36
11
  import argparse
37
12
  import base64
13
+ import html
38
14
  import json
39
15
  import os
40
16
  import re
17
+ import shutil
41
18
  import ssl
42
19
  import subprocess
43
20
  import sys
44
21
  import tempfile
22
+ import time
45
23
  import unicodedata
46
24
  import urllib.error
47
25
  import urllib.parse
48
26
  import urllib.request
49
27
 
50
- FIGMA_API = "https://api.figma.com/v1"
51
- OUT_SUFFIX = ".overlay.png"
28
+ API = "https://api.figma.com/v1"
29
+ SEP = "[:\\-\u2013]"
30
+ TR_RE = re.compile("^(?:tr|tr[kc]e|t[\u00fcu]rk[\u00e7c]e)\\s*" + SEP + "\\s*(.+)$", re.IGNORECASE)
31
+ EN_RE = re.compile("^(?:en|eng|ing|[i\u0130]ngilizce)\\s*" + SEP + "\\s*(.+)$", re.IGNORECASE)
52
32
 
53
- # --- card layout constants (ported from web render-annotations.mjs) --------
54
33
  CARD_W = 320
55
- COL_X = 24
34
+ COLUMN_X = 24
56
35
  CHANNEL = 190
57
- DESIGN_X = COL_X + CARD_W + CHANNEL
58
- RIGHT_MARGIN = 40
59
36
  TOP = 60
60
37
  CARD_H = 116
61
- CARD_GAP = 14
38
+ GAP = 14
39
+ RIGHT_MARGIN = 40
62
40
  PINK = "#EC1E91"
63
41
  GRAY = "#9aa0a6"
42
+ BACKGROUND = "#1e1e1e"
43
+ LABEL_CMS = "\u0130\u00e7erik (CMS)"
44
+ LABEL_PENDING = "\u00d6neri bekleniyor"
45
+ LABEL_UNMATCHED = "(e\u015fle\u015fmedi)"
64
46
 
65
- # Annotation authors separate label from value with a colon, a hyphen, or the
66
- # en-dash their editor auto-inserts. Built from the codepoint so this file
67
- # carries no literal dash variant of its own.
68
- _DASHES = ':\\-' + chr(0x2013)
69
- RE_TR = re.compile(rf"^(?:tr|tr[kc]e|t[üu]rk[çc]e)\s*[{_DASHES}]\s*(.+)$", re.I)
70
- RE_EN = re.compile(rf"^(?:en|eng|ing|[iİ]ngilizce)\s*[{_DASHES}]\s*(.+)$", re.I)
71
-
72
-
73
- def split_lang(raw):
74
- if not raw:
75
- return {"tr": None, "en": None}
76
- lines = [ln.strip() for ln in re.split(r"\r?\n", raw) if ln.strip()]
77
- tr = en = None
78
- bare = []
79
- for ln in lines:
80
- m = RE_TR.match(ln)
81
- if m and tr is None:
82
- tr = m.group(1).strip()
83
- continue
84
- m = RE_EN.match(ln)
85
- if m and en is None:
86
- en = m.group(1).strip()
87
- continue
88
- bare.append(ln)
89
- if tr is not None or en is not None:
90
- bi = 0
91
- if tr is None and bi < len(bare):
92
- tr = bare[bi]; bi += 1
93
- if en is None and bi < len(bare):
94
- en = bare[bi]; bi += 1
95
- return {"tr": tr, "en": en}
96
- if len(bare) >= 2:
97
- return {"tr": bare[0], "en": bare[1]}
98
- if len(bare) == 1:
99
- return {"tr": bare[0], "en": None}
100
- return {"tr": None, "en": None}
101
-
102
-
103
- def canon_id(s):
104
- return re.sub(r"^(\d+)-(\d+)$", r"\1:\2", (s or "").strip())
105
-
106
-
107
- def norm(s):
108
- """NFC → trim → collapse whitespace → strip trailing .:... → lower (web `norm`)."""
109
- if not s:
110
- return ""
111
- s = unicodedata.normalize("NFC", s).strip()
112
- s = re.sub(r"\s+", " ", s)
113
- s = re.sub(r"[.:...]+$", "", s).strip()
114
- return s.lower()
115
-
116
-
117
- def slug(s):
118
- s = (s or "screen").strip()
119
- s = re.sub(r'[/\\:*?"<>|]+', "-", s)
120
- s = re.sub(r"\s+", "-", s)
121
- s = re.sub(r"-{2,}", "-", s)
122
- return s.strip("-").lower() or "screen"
123
-
124
-
125
- def esc(s):
126
- return (str(s) if s is not None else "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
127
-
128
-
129
- # --- Figma token + HTTP (self-contained, mirrors fetch-annotations.py) ------
130
- def figma_token(args):
131
- if args.token:
132
- return args.token
133
- for env in ("FIGMA_ACCESS_TOKEN", "FIGMA_TOKEN"):
134
- v = os.environ.get(env)
135
- if v:
136
- return v.strip()
137
- try:
138
- r = subprocess.run(
139
- ["security", "find-generic-password", "-s", "FIGMA_ACCESS_TOKEN", "-w"],
140
- capture_output=True, text=True)
141
- if r.returncode == 0 and r.stdout.strip():
142
- return r.stdout.strip()
143
- except Exception:
144
- pass
145
- return None
47
+ PAGE_OPEN = "<!doctype html><html><head>"
48
+ META_UTF8 = '<meta charset="utf-8">'
49
+
50
+
51
+ def mac_app(bundle):
52
+ return f"/Applications/{bundle}.app/Contents/MacOS/{bundle}"
53
+
54
+
55
+ MAC_CHROMES = [mac_app(name) for name in ("Google Chrome", "Chromium", "Microsoft Edge")]
56
+ PATH_CHROMES = (
57
+ [f"google-chrome{tail}" for tail in ("", "-stable")]
58
+ + [f"chromium{tail}" for tail in ("", "-browser")]
59
+ + ["chrome"]
60
+ )
61
+
62
+
63
+ def canon_id(node_id):
64
+ return str(node_id or "").strip().replace("-", ":")
65
+
66
+
67
+ def slug(text):
68
+ value = re.sub(r"[\W_]+", "-", str(text or "").strip().lower()).strip("-")
69
+ return value or "screen"
70
+
71
+
72
+ def split_lang(text):
73
+ picked = {"tr": None, "en": None}
74
+ loose = []
75
+ for line in filter(None, (piece.strip() for piece in str(text or "").splitlines())):
76
+ tr_hit, en_hit = TR_RE.match(line), EN_RE.match(line)
77
+ if tr_hit and picked["tr"] is None:
78
+ picked["tr"] = tr_hit.group(1).strip()
79
+ elif en_hit and picked["en"] is None:
80
+ picked["en"] = en_hit.group(1).strip()
81
+ else:
82
+ loose.append(line)
83
+ if picked["tr"] is None and picked["en"] is None:
84
+ padded = loose + [None, None]
85
+ style = {0: "empty", 1: "single"}.get(len(loose), "two-line")
86
+ return {"tr": padded[0], "en": padded[1], "mode": style}
87
+ spare = iter(loose)
88
+ for lang in ("tr", "en"):
89
+ if picked[lang] is None:
90
+ picked[lang] = next(spare, None)
91
+ return dict(picked, mode="prefixed")
92
+
93
+
94
+ def norm(value):
95
+ text = unicodedata.normalize("NFC", str(value or "")).strip()
96
+ text = re.sub(r"\s+", " ", text)
97
+ text = re.sub(r"[.:\u2026]+$", "", text).strip()
98
+ return text.lower()
146
99
 
147
100
 
148
- def _ctx(ca_file):
149
- return ssl.create_default_context(cafile=ca_file) if ca_file else ssl.create_default_context()
101
+ def build_index(rows):
102
+ by_id, by_value = {}, {}
103
+ for position, entry in enumerate(rows):
104
+ if entry.get("cmsNodeId"):
105
+ by_id.setdefault(canon_id(entry["cmsNodeId"]), position)
106
+ texts = [(entry.get(block) or {}).get(lang) for block in ("new", "cms", "legacy") for lang in ("tr", "en")]
107
+ texts.append(entry.get("element"))
108
+ for text in texts:
109
+ normalised = norm(text)
110
+ if normalised:
111
+ by_value.setdefault(normalised, position)
112
+ return {"id": by_id, "value": by_value}
113
+
114
+
115
+ def match_row(node, index):
116
+ node_id = canon_id(node.get("id"))
117
+ if node_id in index["id"]:
118
+ return index["id"][node_id]
119
+ for field in ("tr", "characters"):
120
+ key = norm(node.get(field))
121
+ if key and key in index["value"]:
122
+ return index["value"][key]
123
+ return None
150
124
 
151
125
 
152
- def http_get_json(url, headers, ca_file):
153
- req = urllib.request.Request(url, headers=headers)
126
+ def keychain_secret(service):
154
127
  try:
155
- with urllib.request.urlopen(req, context=_ctx(ca_file), timeout=60) as r:
156
- return json.load(r)
157
- except urllib.error.HTTPError as e:
158
- if e.code == 429:
159
- sys.exit("Figma 429 (rate-limited) - re-run with --spec (MCP path) instead.")
160
- sys.exit(f"Figma HTTP {e.code} for {url}: {e.read()[:200]!r}")
161
- except urllib.error.URLError as e:
162
- sys.exit(f"Figma request failed: {getattr(e, 'reason', e)}")
128
+ lookup = subprocess.run(
129
+ ["security", "find-generic-password", "-s", service, "-w"],
130
+ capture_output=True, text=True, timeout=10,
131
+ )
132
+ except (OSError, subprocess.SubprocessError):
133
+ return None
134
+ secret = lookup.stdout.strip()
135
+ return secret if lookup.returncode == 0 and secret else None
136
+
163
137
 
138
+ def figma_token(explicit):
139
+ if explicit:
140
+ return explicit
141
+ from_env = next((os.environ[v] for v in ("FIGMA_ACCESS_TOKEN", "FIGMA_TOKEN") if os.environ.get(v)), None)
142
+ return from_env or keychain_secret("FIGMA_ACCESS_TOKEN")
164
143
 
165
- def http_get_bytes(url, ca_file):
144
+
145
+ def _open(url, headers, ca_file, timeout):
146
+ tls = ssl.create_default_context(cafile=ca_file) if ca_file else None
166
147
  try:
167
- with urllib.request.urlopen(url, context=_ctx(ca_file), timeout=120) as r:
168
- return r.read()
169
- except Exception as e:
170
- sys.exit(f"image fetch failed: {e}")
171
-
172
-
173
- # --- node collection (REST) ------------------------------------------------
174
- def node_anno(node):
175
- anns = node.get("annotations")
176
- if not isinstance(anns, list):
177
- return {"tr": None, "en": None}
178
- for a in anns:
179
- if not isinstance(a, dict):
180
- continue
181
- lbl = a.get("label") or a.get("labelMarkdown") or ""
182
- if lbl:
183
- p = split_lang(lbl)
184
- if p["tr"] is not None or p["en"] is not None:
185
- return p
186
- return {"tr": None, "en": None}
187
-
188
-
189
- def collect_rest_nodes(file_key, node_id, token, ca_file, scale):
190
- headers = {"X-Figma-Token": token}
191
- data = http_get_json(f"{FIGMA_API}/files/{file_key}/nodes?ids={urllib.parse.quote(node_id, safe='')}",
192
- headers, ca_file)
193
- entry = (data.get("nodes") or {}).get(node_id) or {}
194
- doc = entry.get("document")
195
- if not doc:
196
- sys.exit(f"node {node_id} not found in file {file_key}")
197
- fb = doc.get("absoluteBoundingBox") or {"x": 0, "y": 0, "width": 0, "height": 0}
198
- frame = {"w": round(fb.get("width", 0)), "h": round(fb.get("height", 0))}
199
- nodes = []
148
+ with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=timeout, context=tls) as reply:
149
+ return reply.read()
150
+ except urllib.error.HTTPError as exc:
151
+ if exc.code == 429:
152
+ sys.exit("Figma is rate limiting (HTTP 429). Measure the boxes on the screenshot and supply them through --spec.")
153
+ snippet = exc.read()[:200].decode("utf-8", "replace")
154
+ sys.exit(f"Figma HTTP {exc.code} for {url}: {snippet}")
155
+ except urllib.error.URLError as exc:
156
+ sys.exit(f"Could not reach Figma: {exc.reason}")
200
157
 
201
- def walk(n):
202
- b = n.get("absoluteBoundingBox")
203
- is_text = n.get("type") == "TEXT" and isinstance(n.get("characters"), str) and n["characters"].strip()
204
- ann = node_anno(n)
205
- if b and (is_text or ann["tr"] is not None or ann["en"] is not None):
206
- nodes.append({
207
- "id": n.get("id"),
208
- "characters": n.get("characters") if is_text else "",
209
- "tr": ann["tr"], "en": ann["en"],
210
- "x": round(b["x"] - fb["x"]), "y": round(b["y"] - fb["y"]),
211
- "w": round(b["width"]), "h": round(b["height"]),
212
- })
213
- for c in n.get("children", []) or []:
214
- walk(c)
215
-
216
- walk(doc)
217
-
218
- # image render → data URL
219
- img = http_get_json(
220
- f"{FIGMA_API}/images/{file_key}?ids={urllib.parse.quote(node_id, safe='')}&format=png&scale={scale}",
221
- headers, ca_file)
222
- url = (img.get("images") or {}).get(node_id)
223
- if not url:
224
- sys.exit(f"no image render returned for {node_id}")
225
- raw = http_get_bytes(url, ca_file)
226
- data_url = "data:image/png;base64," + base64.b64encode(raw).decode("ascii")
227
- return {"nodeId": node_id, "image": data_url, "frame": frame, "nodes": nodes}
228
-
229
-
230
- # --- card model ------------------------------------------------------------
231
- def build_index(rows):
232
- by_node, by_val = {}, {}
233
- for r in rows:
234
- key = r.get("newKey")
235
- if not key:
236
- continue
237
- nid = r.get("cmsNodeId")
238
- if nid:
239
- by_node[canon_id(nid)] = r
240
- # index every known value (tr + en across new / cms / legacy) so a node
241
- # matches its key regardless of the frame's language or wording source
242
- for src in (r.get("new") or {}, r.get("cms") or {}, r.get("legacy") or {}):
243
- if isinstance(src, dict):
244
- for lang in ("tr", "en"):
245
- v = src.get(lang)
246
- if v:
247
- by_val.setdefault(norm(v), r)
248
- if r.get("element"):
249
- by_val.setdefault(norm(r["element"]), r)
250
- return by_node, by_val
251
-
252
-
253
- def match_row(node, by_node, by_val):
254
- nid = canon_id(node.get("id") or "")
255
- if nid in by_node:
256
- return by_node[nid]
257
- for v in (node.get("tr"), node.get("characters")):
258
- if v and norm(v) in by_val:
259
- return by_val[norm(v)]
158
+
159
+ def http_json(url, token, ca_file, timeout=60):
160
+ return json.loads(_open(url, {"X-Figma-Token": token}, ca_file, timeout).decode("utf-8"))
161
+
162
+
163
+ def http_bytes(url, ca_file, timeout=120):
164
+ return _open(url, {}, ca_file, timeout)
165
+
166
+
167
+ def as_data_uri(png_bytes):
168
+ return "data:image/png;base64," + base64.b64encode(png_bytes).decode("ascii")
169
+
170
+
171
+ def fetch_node(file_key, node_id, token, ca_file):
172
+ ids = urllib.parse.quote(node_id, safe="")
173
+ reply = http_json(f"{API}/files/{file_key}/nodes?ids={ids}", token, ca_file)
174
+ entry = (reply.get("nodes") or {}).get(node_id) or {}
175
+ return entry.get("document")
176
+
177
+
178
+ def render_image(file_key, node_id, scale, token, ca_file):
179
+ ids = urllib.parse.quote(node_id, safe="")
180
+ reply = http_json(f"{API}/images/{file_key}?ids={ids}&format=png&scale={scale}", token, ca_file)
181
+ link = (reply.get("images") or {}).get(node_id)
182
+ return as_data_uri(http_bytes(link, ca_file)) if link else None
183
+
184
+
185
+ def walk(root):
186
+ stack = [root]
187
+ while stack:
188
+ node = stack.pop()
189
+ if isinstance(node, dict):
190
+ yield node
191
+ stack.extend(reversed(node.get("children") or []))
192
+
193
+
194
+ def annotation_copy(node):
195
+ for note in node.get("annotations") or []:
196
+ text = note.get("label") or note.get("labelMarkdown") if isinstance(note, dict) else note
197
+ parsed = split_lang(text)
198
+ if parsed["tr"] or parsed["en"]:
199
+ return parsed
260
200
  return None
261
201
 
262
202
 
203
+ def image_source(value, base_dir):
204
+ if not value or str(value).startswith("data:"):
205
+ return value
206
+ path = os.path.join(base_dir, value) if not os.path.isabs(value) else value
207
+ if os.path.isfile(path):
208
+ with open(path, "rb") as stream:
209
+ return as_data_uri(stream.read())
210
+ sys.exit(f"the --spec image does not exist: {path}")
211
+
212
+
213
+ def page_from_rest(file_key, node_id, scale, token, ca_file):
214
+ document = fetch_node(file_key, node_id, token, ca_file)
215
+ if not document:
216
+ sys.exit(f"Figma file {file_key} has no node {node_id}")
217
+ box = document.get("absoluteBoundingBox") or {}
218
+ fx, fy = box.get("x", 0), box.get("y", 0)
219
+ nodes = []
220
+ for node in walk(document):
221
+ bbox = node.get("absoluteBoundingBox")
222
+ if not bbox or node is document:
223
+ continue
224
+ copy = annotation_copy(node)
225
+ text = node.get("characters") if node.get("type") == "TEXT" else None
226
+ if not (text and text.strip()) and not copy:
227
+ continue
228
+ nodes.append({
229
+ "id": node.get("id"),
230
+ "characters": text,
231
+ "tr": copy["tr"] if copy else None,
232
+ "en": copy["en"] if copy else None,
233
+ "x": round(bbox["x"] - fx),
234
+ "y": round(bbox["y"] - fy),
235
+ "w": round(bbox["width"]),
236
+ "h": round(bbox["height"]),
237
+ "side": "left",
238
+ })
239
+ image = render_image(file_key, node_id, scale, token, ca_file)
240
+ if not image:
241
+ sys.exit(f"Figma returned no image for {node_id}")
242
+ return {
243
+ "nodeId": node_id,
244
+ "name": document.get("name"),
245
+ "image": image,
246
+ "frame": {"w": round(box.get("width", 0)), "h": round(box.get("height", 0))},
247
+ "nodes": nodes,
248
+ }
249
+
250
+
263
251
  def build_cards(page, rows):
264
- by_node, by_val = build_index(rows)
265
- # 1-based index of every row in the mapping → the SAME number build-artifact prints
266
- # in the summary/detail tables (both enumerate rows in order). The overlay card badge
267
- # uses it so a mark on the screen and its table row always carry the same number.
268
- row_num = {id(r): i for i, r in enumerate(rows, 1)}
252
+ index = build_index(rows)
269
253
  cards = []
270
- for n in page["nodes"]:
271
- row = match_row(n, by_node, by_val)
272
- key = row.get("newKey") if row else None
273
- # CMS values: prefer the node's own annotation, else the matched row's cms.*
274
- tr = n.get("tr")
275
- en = n.get("en")
276
- if tr is None and en is None and row and isinstance(row.get("cms"), dict):
277
- tr, en = row["cms"].get("tr"), row["cms"].get("en")
278
- if not key and tr is None and en is None:
279
- continue # non-keyable
254
+ taken = set()
255
+ extra_number = len(rows)
256
+ for node in page.get("nodes") or []:
257
+ position = match_row(node, index)
258
+ if position is not None and position in taken:
259
+ continue
260
+ entry = rows[position] if position is not None else None
261
+ cms = (entry.get("cms") if entry else {}) or {}
262
+ tr = node.get("tr") or cms.get("tr")
263
+ en = node.get("en") or cms.get("en")
264
+ new_key = entry.get("newKey") if entry else None
265
+ if not (new_key or tr or en):
266
+ continue
267
+ if position is None:
268
+ extra_number += 1
269
+ number = extra_number
270
+ else:
271
+ taken.add(position)
272
+ number = position + 1
273
+ box = {axis: float(node.get(axis, 0)) for axis in ("x", "y", "w", "h")}
280
274
  cards.append({
281
- "key": key or "(eşleşmedi)",
282
- "tr": tr, "en": en,
283
- "designText": n.get("characters") or "",
284
- "x": n["x"], "y": n["y"], "w": n["w"], "h": n["h"],
285
- "annotated": tr is not None or en is not None,
286
- "side": (n.get("side") or "left").lower(),
287
- "num": row_num.get(id(row)) if row else None, # table row number (or None → sequential)
275
+ "num": number,
276
+ "key": new_key or LABEL_UNMATCHED,
277
+ "tr": tr,
278
+ "en": en,
279
+ "text": node.get("characters") or "",
280
+ "annotated": bool(tr or en),
281
+ "side": "right" if node.get("side") == "right" else "left",
282
+ **box,
288
283
  })
289
- cards.sort(key=lambda c: c["y"])
290
284
  return cards
291
285
 
292
286
 
293
- # --- HTML composition ------------------------------------------------------
294
- def build_html(page, cards, title):
295
- """Lay out cards on EITHER side of the frame (per-card `side`: left|right). Cards
296
- that don't read well on the left (e.g. right-column UI like the amenity grid's
297
- right entries) are placed on the right so their connector stays short and doesn't
298
- cross the whole frame. Each column is ordered by target y and greedily stacked;
299
- each link gets its own lane and a numbered dot matching its card. Returns
300
- (html, canvas_w, canvas_h)."""
301
- frame_w = page["frame"]["w"] or 800
302
- frame_h = page["frame"]["h"] or 600
303
- left_card_right = COL_X + CARD_W
304
- design_right = DESIGN_X + frame_w
305
- right_col_x = design_right + CHANNEL
306
-
307
- items = []
308
- for c in cards:
309
- tx = DESIGN_X + c["x"] + c["w"] / 2
310
- ty = TOP + c["y"] + c["h"] / 2
311
- items.append({"tx": tx, "ty": ty, "c": c, "side": c.get("side", "left")})
312
-
313
- left = sorted([it for it in items if it["side"] != "right"], key=lambda it: it["ty"])
314
- right = sorted([it for it in items if it["side"] == "right"], key=lambda it: it["ty"])
315
- has_right = bool(right)
316
-
317
- def place(col):
318
- cursor = TOP
319
- for it in col:
320
- it["y"] = max(cursor, it["ty"] - CARD_H / 2, TOP)
321
- cursor = it["y"] + CARD_H + CARD_GAP
322
- return cursor
323
- cur_l, cur_r = place(left), place(right)
324
-
325
- canvas_w = (right_col_x + CARD_W + RIGHT_MARGIN) if has_right else (design_right + RIGHT_MARGIN)
326
- canvas_h = max(TOP + frame_h + TOP, cur_l + TOP, cur_r + TOP)
327
-
328
- # numbering: use the matched row's table number so the mark on the screen and its
329
- # table row agree; fall back to sequential only for a card with no matched row.
330
- seq = 1
331
- for it in left + right:
332
- n = it["c"].get("num")
333
- if n is None:
334
- n = seq
335
- it["num"] = n
336
- seq += 1
337
-
338
- nL, nR = len(left), len(right)
339
- lane_step_l = (CHANNEL - 30) / max(1, nL - 1) if nL > 1 else 0
340
- lane_step_r = (CHANNEL - 30) / max(1, nR - 1) if nR > 1 else 0
341
-
342
- connectors, card_html = [], []
343
-
344
- def emit(it, idx, side):
345
- c, tx, ty, y, num = it["c"], it["tx"], it["ty"], it["y"], it["num"]
346
- card_mid = y + CARD_H / 2
347
- colour = PINK if c["annotated"] else GRAY
348
- if side == "right":
349
- lane = design_right + 16 + idx * lane_step_r
350
- path = f'M {right_col_x} {card_mid:.0f} H {lane:.0f} V {ty:.0f} H {tx:.0f}'
351
- left_px = right_col_x
352
- else:
353
- lane = left_card_right + 16 + idx * lane_step_l
354
- path = f'M {left_card_right} {card_mid:.0f} H {lane:.0f} V {ty:.0f} H {tx:.0f}'
355
- left_px = COL_X
356
- connectors.append(
357
- f'<path d="{path}" '
358
- f'stroke="{colour}" stroke-width="1.4" stroke-dasharray="5 4" fill="none" opacity="0.85"/>'
359
- f'<circle cx="{tx:.0f}" cy="{ty:.0f}" r="9" fill="{colour}"/>'
360
- f'<text x="{tx:.0f}" y="{ty + 3.5:.0f}" text-anchor="middle" font-size="11" '
361
- f'font-weight="700" fill="#fff">{num}</text>')
362
- badge = f'<span class="num" style="background:{colour}">{num}</span>'
363
- if c["annotated"]:
364
- pill = f'<span class="pill" style="background:{PINK}">İçerik (CMS)</span>'
365
- rows = f'<div class="row"><span class="lang">TR:</span> {esc(c["tr"]) if c["tr"] else "-"}</div>'
366
- if c["en"] is not None:
367
- rows += f'<div class="row"><span class="lang">EN:</span> {esc(c["en"])}</div>'
368
- klass = ""
369
- else:
370
- pill = f'<span class="pill" style="background:{GRAY}">Öneri bekleniyor</span>'
371
- rows = f'<div class="row muted">metin: {esc(c["designText"]) or "-"}</div>'
372
- klass = " keyonly"
373
- card_html.append(
374
- f'<div class="card{klass}" style="left:{left_px:.0f}px;top:{y:.0f}px">'
375
- f'<div class="hd">{badge}{pill}</div>'
376
- f'<div class="key">{esc(c["key"])}</div>{rows}</div>')
377
-
378
- for i, it in enumerate(left):
379
- emit(it, i, "left")
380
- for i, it in enumerate(right):
381
- emit(it, i, "right")
382
-
383
- html = f"""<!doctype html><html><head><meta charset="utf-8"><style>
384
- * {{ box-sizing:border-box; }}
385
- body {{ margin:0; background:#1e1e1e; font-family:-apple-system,'Segoe UI',Roboto,Arial,sans-serif; }}
386
- .canvas {{ position:relative; width:{canvas_w}px; height:{canvas_h:.0f}px; }}
387
- .design {{ position:absolute; left:{DESIGN_X}px; top:{TOP}px; width:{frame_w}px; height:{frame_h}px;
388
- box-shadow:0 0 0 1px #333; }}
389
- svg.links {{ position:absolute; left:0; top:0; width:{canvas_w}px; height:{canvas_h:.0f}px; pointer-events:none; }}
390
- .card {{ position:absolute; width:{CARD_W}px; min-height:{CARD_H}px; background:#2b2b2b; color:#e8e8e8;
391
- border-radius:10px; padding:10px 14px; box-shadow:0 1px 4px rgba(0,0,0,.4); }}
392
- .card.keyonly {{ background:#f4f5f6; color:#202124; }}
393
- .hd {{ display:flex; align-items:center; gap:7px; margin-bottom:6px; }}
394
- .num {{ display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px;
395
- border-radius:50%; color:#fff; font-size:11px; font-weight:700; flex:none; }}
396
- .pill {{ display:inline-block; color:#fff; font-size:11px; font-weight:600; padding:2px 8px;
397
- border-radius:10px; }}
398
- .key {{ font-family:'SF Mono',Menlo,Consolas,monospace; font-size:12px; color:#9ad; margin-bottom:6px;
399
- word-break:break-all; }}
400
- .card.keyonly .key {{ color:#1666c8; }}
401
- .row {{ font-size:13px; line-height:1.35; }}
402
- .row.muted {{ color:#5f6368; }}
403
- .lang {{ color:{PINK}; font-weight:600; }}
404
- .title {{ position:absolute; left:{COL_X}px; top:18px; color:#bbb; font-size:13px; }}
405
- </style></head><body><div class="canvas">
406
- <div class="title">{esc(title)}</div>
407
- <img class="design" src="{page['image']}">
408
- <svg class="links">{''.join(connectors)}</svg>
409
- {''.join(card_html)}
410
- </div></body></html>"""
411
- return html, canvas_w, canvas_h
412
-
413
-
414
- # --- rasterize -------------------------------------------------------------
415
- def find_chrome():
416
- for p in (
417
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
418
- "/Applications/Chromium.app/Contents/MacOS/Chromium",
419
- "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
420
- ):
421
- if os.path.isfile(p) and os.access(p, os.X_OK):
422
- return p
423
- from shutil import which
424
- for c in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"):
425
- w = which(c)
426
- if w:
427
- return w
428
- return None
287
+ def centre_y(card):
288
+ return card["y"] + card["h"] / 2
429
289
 
430
290
 
431
- def render_png(html, out_path, canvas_w, canvas_h, scale):
432
- chrome = find_chrome()
433
- if not chrome:
434
- alt = out_path[:-4] + ".html" if out_path.endswith(".png") else out_path + ".html"
435
- with open(alt, "w", encoding="utf-8") as fh:
436
- fh.write(html)
437
- sys.stderr.write(f"# no headless Chrome found - wrote {alt} (open/print to PDF)\n")
438
- return alt
439
- with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as tf:
440
- tf.write(html)
441
- tmp = tf.name
442
- try:
443
- r = subprocess.run(
444
- [chrome, "--headless", "--disable-gpu", "--hide-scrollbars",
445
- f"--force-device-scale-factor={scale}",
446
- f"--window-size={canvas_w},{int(canvas_h)}",
447
- f"--screenshot={out_path}", tmp],
448
- capture_output=True, text=True, timeout=120)
449
- if not os.path.isfile(out_path):
450
- sys.exit(f"Chrome screenshot failed: {r.stderr[:300]}")
451
- finally:
452
- try:
453
- os.unlink(tmp)
454
- except OSError:
455
- pass
456
- return out_path
291
+ def stack_side(cards):
292
+ cards.sort(key=centre_y)
293
+ bottom = TOP - GAP
294
+ for card in cards:
295
+ target = TOP + card["y"] + card["h"] / 2 - CARD_H / 2
296
+ card["cardY"] = max(target, bottom + GAP, TOP)
297
+ bottom = card["cardY"] + CARD_H
298
+ return bottom
299
+
300
+
301
+ def chip(kind, colour, content):
302
+ return f'<span class="{kind}" style="background:{colour}">{content}</span>'
303
+
304
+
305
+ def card_html(card, left):
306
+ colour = PINK if card["annotated"] else GRAY
307
+ pill = LABEL_CMS if card["annotated"] else LABEL_PENDING
308
+ if card["annotated"]:
309
+ body = "".join(
310
+ f'<div class="line"><b>{label}</b> {html.escape(card[lang] or "-")}</div>'
311
+ for label, lang in (("TR", "tr"), ("EN", "en"))
312
+ )
313
+ else:
314
+ body = f'<div class="line muted">{html.escape(card["text"] or "-")}</div>'
315
+ head = chip("num", colour, card["num"]) + chip("pill", colour, html.escape(pill))
316
+ return (
317
+ f'<div class="card" style="left:{left}px;top:{card["cardY"]:.0f}px;border-color:{colour}">'
318
+ f'<div class="head">{head}</div>'
319
+ f'<div class="key">{html.escape(card["key"])}</div>{body}</div>'
320
+ )
321
+
322
+
323
+ def connector(card, lane, lane_count, start_x, target_x, from_left):
324
+ colour = PINK if card["annotated"] else GRAY
325
+ step = (CHANNEL - 40) / max(lane_count, 1)
326
+ bend = start_x + 20 + step * lane if from_left else start_x - 20 - step * lane
327
+ card_mid = card["cardY"] + CARD_H / 2
328
+ dot_y = TOP + centre_y(card)
329
+ x, y = f"{target_x:.0f}", f"{dot_y:.0f}"
330
+ label_y = f"{dot_y + 4:.0f}"
331
+ route = f"{start_x:.0f},{card_mid:.0f} {bend:.0f},{card_mid:.0f} {bend:.0f},{y} {x},{y}"
332
+ return (
333
+ f'<polyline points="{route}" fill="none" stroke="{colour}" stroke-width="2" stroke-dasharray="6 4"/>'
334
+ f'<circle cx="{x}" cy="{y}" r="11" fill="{colour}"/>'
335
+ f'<text x="{x}" y="{label_y}" text-anchor="middle" font-size="12" font-weight="700" fill="#fff">{card["num"]}</text>'
336
+ )
337
+
338
+
339
+ def build_html(page, cards):
340
+ fw, fh = page["frame"]["w"], page["frame"]["h"]
341
+ sides = {"left": [], "right": []}
342
+ for card in cards:
343
+ sides[card["side"]].append(card)
344
+ screen_x = COLUMN_X + CARD_W + CHANNEL
345
+ right_x = screen_x + fw + CHANNEL
346
+ width = right_x + CARD_W + RIGHT_MARGIN if sides["right"] else screen_x + fw + RIGHT_MARGIN
347
+ height = int(max(TOP + fh, stack_side(sides["left"]), stack_side(sides["right"])) + TOP)
348
+
349
+ boxes, wires = [], []
350
+ for card_x, group in ((COLUMN_X, sides["left"]), (right_x, sides["right"])):
351
+ from_left = card_x == COLUMN_X
352
+ for lane, card in enumerate(group):
353
+ boxes.append(card_html(card, card_x))
354
+ if from_left:
355
+ wire = connector(card, lane, len(group), COLUMN_X + CARD_W, screen_x + card["x"], True)
356
+ else:
357
+ wire = connector(card, lane, len(group), right_x, screen_x + card["x"] + card["w"], False)
358
+ wires.append(wire)
359
+
360
+ mid = "center"
361
+ pin = "left:0;top:0;"
362
+ style = (
363
+ f"html,body{{margin:0;padding:0;background:{BACKGROUND};}}"
364
+ f"body{{width:{width}px;height:{height}px;position:relative;font-family:-apple-system,Helvetica,Arial,sans-serif;}}"
365
+ f".shot{{position:absolute;left:{screen_x}px;top:{TOP}px;width:{fw}px;height:{fh}px;}}"
366
+ f".card{{position:absolute;box-sizing:border-box;width:{CARD_W}px;height:{CARD_H}px;background:#fff;"
367
+ "border:2px solid;border-radius:10px;padding:8px 10px;overflow:hidden;color:#1f1f1f;}}"
368
+ ".head{display:flex;gap:6px;align-items:center;margin-bottom:4px;}"
369
+ ".num{color:#fff;border-radius:10px;min-width:20px;height:20px;font-size:12px;font-weight:700;"
370
+ f"display:inline-flex;align-items:{mid};justify-content:{mid};padding:0 4px;}}"
371
+ ".pill{color:#fff;border-radius:9px;font-size:11px;padding:2px 8px;}"
372
+ ".key{font-family:Menlo,monospace;font-size:11px;color:#444;margin-bottom:4px;white-space:nowrap;"
373
+ "overflow:hidden;text-overflow:ellipsis;}"
374
+ ".line{font-size:12px;line-height:16px;max-height:32px;overflow:hidden;}"
375
+ ".muted{color:#666;font-style:italic;}"
376
+ f"svg{{position:absolute;{pin}}}"
377
+ )
378
+ document = (
379
+ f"{PAGE_OPEN}{META_UTF8}<style>{style}</style></head><body>"
380
+ f'<img class="shot" src="{page["image"]}">'
381
+ f'<svg width="{width}" height="{height}">{"".join(wires)}</svg>'
382
+ f'{"".join(boxes)}</body></html>'
383
+ )
384
+ return document, width, height
385
+
386
+
387
+ def find_chrome():
388
+ for path in [os.environ.get("CHROME_BIN")] + MAC_CHROMES:
389
+ if path and os.path.isfile(path) and os.access(path, os.X_OK):
390
+ return path
391
+ return next(filter(None, map(shutil.which, PATH_CHROMES)), None)
392
+
393
+
394
+ def wait_until_stable(proc, path, limit, pause):
395
+ deadline = time.time() + limit
396
+ previous = -1
397
+ while time.time() < deadline and proc.poll() is None:
398
+ current = os.path.getsize(path) if os.path.exists(path) else -1
399
+ if current > 0 and current == previous:
400
+ break
401
+ previous = current
402
+ time.sleep(pause)
403
+ if proc.poll() is None:
404
+ proc.kill()
405
+ proc.wait()
406
+
407
+
408
+ def chrome_screenshot(chrome, html_text, out_png, width, height, scale):
409
+ target = os.path.abspath(out_png)
410
+ with tempfile.TemporaryDirectory() as scratch:
411
+ page = os.path.join(scratch, "page.html")
412
+ with open(page, "w", encoding="utf-8") as sink:
413
+ sink.write(html_text)
414
+ if os.path.exists(target):
415
+ os.remove(target)
416
+ log_path = os.path.join(scratch, "chrome.log")
417
+ window = f"{int(width)},{int(height)}"
418
+ argv = [
419
+ chrome, "--hide-scrollbars", "--headless", "--disable-gpu",
420
+ f"--window-size={window}", f"--force-device-scale-factor={scale}",
421
+ f"--screenshot={target}", f"--user-data-dir={os.path.join(scratch, 'profile')}",
422
+ "file://" + page,
423
+ ]
424
+ with open(log_path, "w") as log:
425
+ proc = subprocess.Popen(argv, stdout=log, stderr=subprocess.STDOUT)
426
+ wait_until_stable(proc, target, 120, 0.5)
427
+ if os.path.isfile(target) and os.path.getsize(target) > 0:
428
+ return
429
+ with open(log_path, errors="replace") as log:
430
+ raise RuntimeError((log.read() or "no output").strip()[-300:])
431
+
432
+
433
+ def load_spec_pages(path):
434
+ with open(path, encoding="utf-8") as stream:
435
+ spec = json.load(stream)
436
+ folder = os.path.dirname(os.path.abspath(path))
437
+ pages = spec.get("pages") or []
438
+ for page in pages:
439
+ page["image"] = image_source(page.get("image"), folder)
440
+ return spec, pages
441
+
442
+
443
+ def split_nodes(flag, mapping):
444
+ given = [piece for piece in (flag or "").split(",") if piece.strip()]
445
+ return given or list(mapping.get("figmaNodes") or [])
457
446
 
458
447
 
459
448
  def main():
460
- ap = argparse.ArgumentParser(description=__doc__,
461
- formatter_class=argparse.RawDescriptionHelpFormatter)
462
- ap.add_argument("--mapping", required=True, help="mapping JSON (rows + figmaFileKey/figmaNodes + screen)")
463
- ap.add_argument("--spec", help="MCP spec JSON (no network)")
464
- ap.add_argument("--file", help="Figma fileKey (REST; else from --mapping figmaFileKey)")
465
- ap.add_argument("--nodes", help="node-ids (REST; else from --mapping figmaNodes)")
466
- ap.add_argument("--scale", type=int, default=2)
467
- ap.add_argument("--token")
468
- ap.add_argument("--ca-file")
469
- ap.add_argument("--out", default=".", help="output dir")
470
- ap.add_argument("--slug", help="override slug (default from screen name)")
471
- args = ap.parse_args()
472
-
473
- with open(args.mapping, encoding="utf-8") as fh:
474
- m = json.load(fh)
475
- rows = m.get("rows") or []
476
- screen = m.get("screen") or "screen"
477
- sl = args.slug or slug(screen)
478
- os.makedirs(args.out, exist_ok=True)
479
-
480
- file_key = args.file or m.get("figmaFileKey")
481
- node_ids = [n.strip() for n in (args.nodes or "").split(",") if n.strip()] or \
482
- [str(n) for n in (m.get("figmaNodes") or [])]
483
-
484
- # one overlay for the screen: use the FIRST node as the framing node (the screen frame)
485
- if args.spec:
486
- with open(args.spec, encoding="utf-8") as fh:
487
- spec = json.load(fh)
488
- pages = spec.get("pages") or []
449
+ cli = argparse.ArgumentParser(description=__doc__.splitlines()[0])
450
+ cli.add_argument("--mapping", required=True, help="the reuse-map JSON")
451
+ cli.add_argument("--spec", help="geometry from MCP or measured by hand: {fileKey, pages:[{nodeId, image, frame, nodes}]}")
452
+ cli.add_argument("--file", help="Figma file key; falls back to figmaFileKey in the mapping")
453
+ cli.add_argument("--nodes", help="node-ids separated by commas, the screen frame first; falls back to figmaNodes")
454
+ cli.add_argument("--scale", default=2, type=int)
455
+ cli.add_argument("--token", help="Figma token; FIGMA_ACCESS_TOKEN or the keychain is safer since other processes can read argv")
456
+ cli.add_argument("--ca-file", help="PEM roots to trust when a proxy intercepts TLS")
457
+ cli.add_argument("--out", dest="target_dir", default=".", help="folder that receives the generated files")
458
+ cli.add_argument("--slug", help="stem for the file names; derived from the screen name when omitted")
459
+ opts = cli.parse_args()
460
+
461
+ with open(opts.mapping, encoding="utf-8") as stream:
462
+ mapping = json.load(stream)
463
+ stem = opts.slug or slug(mapping.get("screen"))
464
+
465
+ if opts.spec:
466
+ spec, pages = load_spec_pages(opts.spec)
489
467
  if not pages:
490
- sys.exit("--spec has no pages")
468
+ sys.exit("--spec lists no pages")
491
469
  page = pages[0]
492
- file_key = file_key or spec.get("fileKey")
470
+ file_key = spec.get("fileKey") or mapping.get("figmaFileKey")
493
471
  else:
494
- if not (file_key and node_ids):
495
- ap.error("REST needs a fileKey + node-ids (via --file/--nodes or the mapping). "
496
- "Or pass --spec for the MCP path.")
497
- token = figma_token(args)
472
+ file_key = opts.file or mapping.get("figmaFileKey")
473
+ nodes = split_nodes(opts.nodes, mapping)
474
+ if not (file_key and nodes):
475
+ cli.error("need a Figma file key and frame node-id (flags or mapping), or --spec")
476
+ token = figma_token(opts.token)
498
477
  if not token:
499
- sys.exit("No Figma token - pass --token, set FIGMA_ACCESS_TOKEN, or add the "
500
- "keychain item FIGMA_ACCESS_TOKEN (or use --spec).")
501
- page = collect_rest_nodes(file_key, canon_id(node_ids[0]), token, args.ca_file, args.scale)
502
- page["name"] = screen
478
+ sys.exit("No Figma token found (FIGMA_ACCESS_TOKEN, FIGMA_TOKEN, keychain FIGMA_ACCESS_TOKEN or --token). "
479
+ "Without one, measure the boxes on the screenshot and hand them over with --spec.")
480
+ page = page_from_rest(file_key, canon_id(nodes[0]), opts.scale, token, opts.ca_file)
503
481
 
504
- cards = build_cards(page, rows)
505
- if not cards:
506
- sys.stderr.write("# warning: no keyable cards built (no node matched a row / no annotations)\n")
507
- html, canvas_w, canvas_h = build_html(page, cards, screen)
508
- out_png = os.path.join(args.out, sl + OUT_SUFFIX)
509
- written = render_png(html, out_png, canvas_w, canvas_h, args.scale)
482
+ cards = build_cards(page, mapping.get("rows") or [])
483
+ document, width, height = build_html(page, cards)
484
+ os.makedirs(opts.target_dir, exist_ok=True)
485
+
486
+ def output(suffix):
487
+ return os.path.join(opts.target_dir, f"{stem}.overlay.{suffix}")
510
488
 
489
+ chrome = find_chrome()
490
+ if chrome:
491
+ written = output("png")
492
+ try:
493
+ chrome_screenshot(chrome, document, written, width, height, opts.scale)
494
+ except (RuntimeError, subprocess.SubprocessError) as exc:
495
+ sys.exit(f"Chrome could not take the screenshot: {exc}")
496
+ else:
497
+ written = output("html")
498
+ with open(written, "w", encoding="utf-8") as sink:
499
+ sink.write(document)
500
+ print(f"# Chrome, Chromium and Edge are all missing; saved {written} in place of a PNG", file=sys.stderr)
501
+
502
+ with_copy = len([card for card in cards if card["annotated"]])
511
503
  manifest = {
512
- "screen": screen,
504
+ "screen": mapping.get("screen"),
513
505
  "file": os.path.basename(written),
514
506
  "fileKey": file_key,
515
- "nodeId": canon_id(node_ids[0]) if node_ids else page.get("nodeId"),
507
+ "nodeId": page.get("nodeId"),
516
508
  "cardCount": len(cards),
517
- "annotatedCount": sum(1 for c in cards if c["annotated"]),
518
- "width": canvas_w, "height": int(canvas_h), "scale": args.scale,
509
+ "annotatedCount": with_copy,
510
+ "width": width,
511
+ "height": height,
512
+ "scale": opts.scale,
519
513
  }
520
- mpath = os.path.join(args.out, sl + ".overlay.manifest.json")
521
- with open(mpath, "w", encoding="utf-8") as fh:
522
- json.dump(manifest, fh, ensure_ascii=False, indent=2)
523
- fh.write("\n")
524
- sys.stderr.write(f"# overlay: {len(cards)} card(s), {manifest['annotatedCount']} annotated → "
525
- f"{written}\n")
514
+ with open(output("manifest.json"), "w", encoding="utf-8") as sink:
515
+ json.dump(manifest, sink, indent=2, ensure_ascii=False)
516
+ sink.write("\n")
517
+
518
+ if not cards:
519
+ print("# warning: the overlay is empty; no node matched a row and no node had an annotation", file=sys.stderr)
520
+ print(f"# overlay: {len(cards)} card(s), {with_copy} with CMS copy -> {written}", file=sys.stderr)
526
521
 
527
522
 
528
523
  if __name__ == "__main__":