@mmerterden/multi-agent-pipeline 20.6.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.
- package/CHANGELOG.md +18 -0
- package/docs/facts.json +1 -1
- package/manifest.json +71 -71
- package/package.json +1 -1
- package/pipeline/multi-agent-refs/features/design-conformance.md +62 -64
- package/pipeline/scripts/_notices.mjs +11 -0
- package/pipeline/scripts/gen-skills-index.mjs +1 -1
- package/pipeline/skills/.skill-manifest.json +39 -39
- package/pipeline/skills/shared/README.md +1 -1
- package/pipeline/skills/shared/external/agent-introspection-debugging/SKILL.md +1 -0
- package/pipeline/skills/shared/external/android-architecture/SKILL.md +2 -0
- package/pipeline/skills/shared/external/android-performance/SKILL.md +2 -0
- package/pipeline/skills/shared/external/android-security/SKILL.md +2 -0
- package/pipeline/skills/shared/external/backlog/BACKLOG.md +1 -1
- package/pipeline/skills/shared/external/backlog/SKILL.md +56 -33
- package/pipeline/skills/shared/external/ci-cd-pipelines/SKILL.md +1 -0
- package/pipeline/skills/shared/external/compose-components/SKILL.md +2 -0
- package/pipeline/skills/shared/external/compose-navigation/SKILL.md +3 -2
- package/pipeline/skills/shared/external/compose-testing/SKILL.md +2 -0
- package/pipeline/skills/shared/external/council/SKILL.md +1 -0
- package/pipeline/skills/shared/external/css-modern/SKILL.md +1 -0
- package/pipeline/skills/shared/external/database-patterns/SKILL.md +1 -0
- package/pipeline/skills/shared/external/evidence-github/SKILL.md +2 -0
- package/pipeline/skills/shared/external/evidence-registry/SKILL.md +2 -0
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +2 -0
- package/pipeline/skills/shared/external/html-semantic/SKILL.md +1 -0
- package/pipeline/skills/shared/external/humanizer/SKILL.md +1 -0
- package/pipeline/skills/shared/external/ios-coding-standard/SKILL.md +1 -0
- package/pipeline/skills/shared/external/ios-module-structure/SKILL.md +1 -0
- package/pipeline/skills/shared/external/ios-security/SKILL.md +2 -0
- package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +91 -283
- package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +119 -151
- package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +60 -90
- package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +119 -156
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +726 -787
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +253 -288
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +243 -304
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +88 -104
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +181 -235
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +198 -263
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +461 -466
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +145 -151
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +123 -141
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +146 -157
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +22 -19
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +156 -140
- package/pipeline/skills/shared/external/nextjs-app-router/SKILL.md +1 -0
- package/pipeline/skills/shared/external/play-store-review/SKILL.md +2 -0
- package/pipeline/skills/shared/external/python-patterns/SKILL.md +1 -0
- package/pipeline/skills/shared/external/react-best-practices/SKILL.md +1 -0
- package/pipeline/skills/shared/external/rest-api-design/SKILL.md +1 -0
- package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +2 -0
- package/pipeline/skills/shared/external/room-database/SKILL.md +2 -0
- package/pipeline/skills/shared/external/search-first/SKILL.md +1 -0
- package/pipeline/skills/shared/external/signal-community/SKILL.md +2 -0
- package/pipeline/skills/shared/external/skill-creator/SKILL.md +80 -41
- package/pipeline/skills/shared/external/skill-creator/audit.md +63 -59
- package/pipeline/skills/shared/external/skill-creator/checklist.md +28 -20
- package/pipeline/skills/shared/external/skill-creator/examples.md +40 -40
- package/pipeline/skills/shared/external/skill-creator/label-check.md +48 -36
- package/pipeline/skills/shared/external/skill-creator/scripts/audit-panel.js +91 -100
- package/pipeline/skills/shared/external/skill-creator/template.md +51 -39
- package/pipeline/skills/shared/external/tailwind-css/SKILL.md +1 -0
- package/pipeline/skills/shared/external/testing-backend/SKILL.md +1 -0
- package/pipeline/skills/shared/external/typescript-patterns/SKILL.md +1 -0
- package/pipeline/skills/shared/external/vue-composition/SKILL.md +1 -0
- package/pipeline/skills/shared/external/web-accessibility/SKILL.md +1 -0
- package/pipeline/skills/shared/external/web-performance/SKILL.md +1 -0
- package/pipeline/skills/shared/external/web-testing/SKILL.md +1 -0
|
@@ -1,297 +1,232 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
|
|
4
|
-
the
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
with ANCESTOR VISIBILITY (`visible!=false`) + cumulative opacity tracked,
|
|
11
|
-
so hidden component variants with stale bboxes are never boxed;
|
|
12
|
-
image GET /v1/images/<key>?ids=<frame>&format=png&scale=<n> → one render,
|
|
13
|
-
cropped per key.
|
|
14
|
-
* --spec <file> (repeatable; MCP / offline) - the SAME spec shape render-overlay.py
|
|
15
|
-
takes: { fileKey?, pages:[{nodeId, image, frame:{w,h}, nodes:[{id, characters,
|
|
16
|
-
x,y,w,h}]}] }. `image` may be a data URL **or a file path** (resolved relative to
|
|
17
|
-
the spec file) - a committed example spec can reference screenshot.png instead of
|
|
18
|
-
inlining base64. Several --spec files = several screen states; each row is boxed
|
|
19
|
-
on the FIRST spec/page where its node matches.
|
|
20
|
-
|
|
21
|
-
Rows are matched to nodes exactly like the overlay: by `cmsNodeId` first, else by
|
|
22
|
-
normalized value (new/cms/legacy tr+en, element label). Every matched row gets a shot;
|
|
23
|
-
unmatched rows are listed in the manifest's `missing` (blank cell in the table - never
|
|
24
|
-
a guessed crop).
|
|
25
|
-
|
|
26
|
-
Output, into --out:
|
|
27
|
-
keyshots/keyshot__<NN>__<key>.png one per matched row; NN = the row's 1-based
|
|
28
|
-
position in mapping.rows - the SAME number the
|
|
29
|
-
table and the overlay cards use.
|
|
30
|
-
<slug>.keyshots.manifest.json {shots:[{row,key,element,file,...}], missing:[...]}
|
|
31
|
-
build-artifact.py reads this (mapping top-level
|
|
32
|
-
`keyshots`) to fill the table cells;
|
|
33
|
-
publish-confluence.py --keyshots attaches the PNGs.
|
|
34
|
-
No headless Chrome → per-shot `.html` files are written instead (open/print manually);
|
|
35
|
-
the manifest marks them `"fallback": "html"`. Read-only against every source.
|
|
2
|
+
"""Cut one crop per mapped row, with the element framed in red, for the Screenshot column.
|
|
3
|
+
|
|
4
|
+
A crop keeps the whole frame width and a strip of --pad pixels over and under
|
|
5
|
+
the element, which gets a red outline. Output lands in
|
|
6
|
+
keyshots/keyshot__NN__<key>.png, NN being the row's index in mapping.rows
|
|
7
|
+
counted from 1. A row whose element cannot be found on screen is reported as
|
|
8
|
+
missing and gets no picture; crops are never guessed. Node matching and the
|
|
9
|
+
Figma calls are shared with render-overlay.py, loaded from this folder.
|
|
36
10
|
"""
|
|
37
11
|
import argparse
|
|
38
|
-
import base64
|
|
39
12
|
import importlib.util
|
|
40
13
|
import json
|
|
41
14
|
import os
|
|
42
15
|
import re
|
|
16
|
+
import subprocess
|
|
43
17
|
import sys
|
|
44
|
-
import tempfile
|
|
45
|
-
|
|
46
|
-
sys.dont_write_bytecode = True
|
|
47
|
-
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
48
|
-
sys.path.insert(0, _HERE)
|
|
49
18
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
_spec = importlib.util.spec_from_file_location("render_overlay", _RO_PATH)
|
|
57
|
-
if _spec is None or _spec.loader is None:
|
|
58
|
-
sys.exit(f"render-key-shots: cannot load {_RO_PATH}")
|
|
59
|
-
_ro = importlib.util.module_from_spec(_spec)
|
|
60
|
-
sys.modules["render_overlay"] = _ro
|
|
61
|
-
_spec.loader.exec_module(_ro)
|
|
19
|
+
SCRIPT_DIR = os.path.split(os.path.abspath(__file__))[0]
|
|
20
|
+
BOX_COLOUR = "#E81932"
|
|
21
|
+
ID_FIELD = "cmsNodeId"
|
|
22
|
+
INSET = 6
|
|
23
|
+
UNSAFE = re.compile(r"[\\/:*?\"<>|\s]+")
|
|
24
|
+
TRAILING_MARKER = re.compile(r"\s*\([^()]*\)\s*$")
|
|
62
25
|
|
|
63
|
-
canon_id = _ro.canon_id
|
|
64
|
-
slug = _ro.slug
|
|
65
|
-
build_index = _ro.build_index
|
|
66
|
-
match_row = _ro.match_row
|
|
67
|
-
figma_token = _ro.figma_token
|
|
68
|
-
http_get_json = _ro.http_get_json
|
|
69
|
-
http_get_bytes = _ro.http_get_bytes
|
|
70
|
-
find_chrome = _ro.find_chrome
|
|
71
|
-
FIGMA_API = _ro.FIGMA_API
|
|
72
26
|
|
|
73
|
-
|
|
27
|
+
def load_overlay():
|
|
28
|
+
path = os.path.join(SCRIPT_DIR, "render-overlay.py")
|
|
29
|
+
try:
|
|
30
|
+
spec = importlib.util.spec_from_file_location("render_overlay", path)
|
|
31
|
+
module = importlib.util.module_from_spec(spec)
|
|
32
|
+
spec.loader.exec_module(module)
|
|
33
|
+
except Exception as exc:
|
|
34
|
+
sys.exit(f"cannot load {path}: {exc}")
|
|
35
|
+
return module
|
|
74
36
|
|
|
75
37
|
|
|
76
|
-
|
|
77
|
-
# Drop a trailing "(owned by ...)" marker first: it is provenance, not identity, so
|
|
78
|
-
# letting it into the filename churns every PNG (and orphans the old ones) whenever
|
|
79
|
-
# a component is renamed or the tag is added. Matches build-spreadsheet's clean_key.
|
|
80
|
-
bare = re.sub(r"\s*\(.*\)\s*$", "", key or "").strip()
|
|
81
|
-
safe = re.sub(r'[/\\:*?"<>|\s]+', "-", bare or "key").strip("-")
|
|
82
|
-
return f"keyshot__{num:02d}__{safe}.png"
|
|
38
|
+
ov = load_overlay()
|
|
83
39
|
|
|
84
40
|
|
|
85
|
-
def
|
|
86
|
-
"""
|
|
87
|
-
|
|
88
|
-
cy1 = min(fh, box["y"] + box["h"] + pad)
|
|
89
|
-
crop_w, crop_h = fw, cy1 - cy0
|
|
90
|
-
bx = max(0, box["x"] - 6)
|
|
91
|
-
by = box["y"] - cy0 - 6
|
|
92
|
-
bw, bh = box["w"] + 12, box["h"] + 12
|
|
93
|
-
html = f"""<!doctype html><html><head><meta charset="utf-8"><style>
|
|
94
|
-
*{{margin:0;padding:0;box-sizing:border-box}}
|
|
95
|
-
body{{width:{crop_w}px;height:{crop_h}px;overflow:hidden;position:relative;background:#fff}}
|
|
96
|
-
img{{position:absolute;left:0;top:{-cy0}px;width:{fw}px;height:{fh}px}}
|
|
97
|
-
.box{{position:absolute;left:{bx}px;top:{by}px;width:{bw}px;height:{bh}px;
|
|
98
|
-
border:3px solid {RED};border-radius:6px;box-shadow:0 0 0 2px rgba(232,25,50,.15)}}
|
|
99
|
-
</style></head><body><img src="{img_src.replace('"', '"')}"/><div class="box"></div></body></html>"""
|
|
100
|
-
return html, crop_w, crop_h
|
|
41
|
+
def shot_name(row_number, key):
|
|
42
|
+
clean = UNSAFE.sub("-", TRAILING_MARKER.sub("", key).strip()).strip("-")
|
|
43
|
+
return f"keyshot__{row_number:02d}__{clean}"
|
|
101
44
|
|
|
102
45
|
|
|
103
|
-
def
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
[chrome, "--headless", "--disable-gpu", "--hide-scrollbars",
|
|
112
|
-
f"--force-device-scale-factor={scale}",
|
|
113
|
-
f"--window-size={w},{int(h)}",
|
|
114
|
-
f"--screenshot={out_path}", tmp],
|
|
115
|
-
capture_output=True, text=True, timeout=120)
|
|
116
|
-
if not os.path.isfile(out_path):
|
|
117
|
-
return (r.stderr or "no output")[:200]
|
|
118
|
-
return None
|
|
119
|
-
except Exception as exc: # timeout / spawn failure - fall back per shot, not per run
|
|
120
|
-
return str(exc)[:200]
|
|
121
|
-
finally:
|
|
122
|
-
try:
|
|
123
|
-
os.unlink(tmp)
|
|
124
|
-
except OSError:
|
|
125
|
-
pass
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def spec_image_src(page, spec_path):
|
|
129
|
-
"""`image` as a browser src: pass a data URL through; inline a file path (relative
|
|
130
|
-
to the spec file) as base64 so the crop HTML stays self-contained for Chrome."""
|
|
131
|
-
img = page.get("image") or ""
|
|
132
|
-
if img.startswith("data:"):
|
|
133
|
-
return img
|
|
134
|
-
cand = img if os.path.isabs(img) else os.path.join(os.path.dirname(os.path.abspath(spec_path)), img)
|
|
135
|
-
if not os.path.isfile(cand):
|
|
136
|
-
sys.exit(f"spec image not found: {cand}")
|
|
137
|
-
with open(cand, "rb") as fh:
|
|
138
|
-
return "data:image/png;base64," + base64.b64encode(fh.read()).decode("ascii")
|
|
46
|
+
def spec_pages(paths):
|
|
47
|
+
pages = []
|
|
48
|
+
for path in paths:
|
|
49
|
+
spec, loaded = ov.load_spec_pages(path)
|
|
50
|
+
if not loaded:
|
|
51
|
+
sys.exit(f"--spec lists no pages: {path}")
|
|
52
|
+
pages.extend(loaded)
|
|
53
|
+
return pages
|
|
139
54
|
|
|
140
55
|
|
|
141
|
-
def
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
56
|
+
def visible_text_nodes(document):
|
|
57
|
+
frame = document.get("absoluteBoundingBox") or {}
|
|
58
|
+
fx, fy = frame.get("x", 0), frame.get("y", 0)
|
|
59
|
+
found = []
|
|
60
|
+
pending = [(document, True, 1.0)]
|
|
61
|
+
while pending:
|
|
62
|
+
node, shown, alpha = pending.pop()
|
|
63
|
+
shown = shown and node.get("visible", True) is not False
|
|
64
|
+
alpha = alpha * float(node.get("opacity", 1.0))
|
|
65
|
+
if not shown or alpha <= 0:
|
|
66
|
+
continue
|
|
67
|
+
box = node.get("absoluteBoundingBox")
|
|
68
|
+
text = node.get("characters")
|
|
69
|
+
is_text = node.get("type") == "TEXT"
|
|
70
|
+
if is_text and box and text and text.strip():
|
|
71
|
+
found.append({
|
|
72
|
+
"id": node.get("id"),
|
|
73
|
+
"characters": text,
|
|
74
|
+
"x": round(box["x"] - fx),
|
|
75
|
+
"y": round(box["y"] - fy),
|
|
76
|
+
"w": round(box["width"]),
|
|
77
|
+
"h": round(box["height"]),
|
|
78
|
+
})
|
|
79
|
+
pending.extend((child, shown, alpha) for child in reversed(node.get("children") or []))
|
|
80
|
+
return found
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def rest_pages(file_key, node_ids, scale, token, ca_file):
|
|
148
84
|
pages = []
|
|
149
|
-
for
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
sys.stderr.write(f"# warning: node {nid} not found - skipped\n")
|
|
85
|
+
for raw in node_ids:
|
|
86
|
+
node_id = ov.canon_id(raw)
|
|
87
|
+
document = ov.fetch_node(file_key, node_id, token, ca_file)
|
|
88
|
+
if not document:
|
|
89
|
+
print(f"# warning: {file_key} has no frame {node_id}; skipping it", file=sys.stderr)
|
|
155
90
|
continue
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def walk(n, anc_visible, opacity):
|
|
160
|
-
vis = anc_visible and n.get("visible") is not False
|
|
161
|
-
op = opacity * (n.get("opacity") if isinstance(n.get("opacity"), (int, float)) else 1)
|
|
162
|
-
b = n.get("absoluteBoundingBox")
|
|
163
|
-
is_text = n.get("type") == "TEXT" and isinstance(n.get("characters"), str) and n["characters"].strip()
|
|
164
|
-
if is_text and b and vis and op > 0:
|
|
165
|
-
nodes.append({"id": n.get("id"), "characters": n["characters"],
|
|
166
|
-
"x": round(b["x"] - fb["x"]), "y": round(b["y"] - fb["y"]),
|
|
167
|
-
"w": round(b["width"]), "h": round(b["height"])})
|
|
168
|
-
for c in n.get("children") or []:
|
|
169
|
-
walk(c, vis, op)
|
|
170
|
-
|
|
171
|
-
walk(doc, True, 1)
|
|
172
|
-
img = http_get_json(
|
|
173
|
-
f"{FIGMA_API}/images/{file_key}?ids={urllib.parse.quote(nid, safe='')}&format=png&scale={scale}",
|
|
174
|
-
headers, ca_file)
|
|
175
|
-
url = (img.get("images") or {}).get(nid)
|
|
176
|
-
if not url:
|
|
177
|
-
sys.stderr.write(f"# warning: no image render for {nid} - skipped\n")
|
|
91
|
+
image = ov.render_image(file_key, node_id, scale, token, ca_file)
|
|
92
|
+
if not image:
|
|
93
|
+
print(f"# warning: Figma returned no render of {node_id}; skipping it", file=sys.stderr)
|
|
178
94
|
continue
|
|
179
|
-
|
|
180
|
-
pages.append({
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
95
|
+
box = document.get("absoluteBoundingBox") or {}
|
|
96
|
+
pages.append({
|
|
97
|
+
"nodeId": node_id,
|
|
98
|
+
"name": document.get("name"),
|
|
99
|
+
"image": image,
|
|
100
|
+
"frame": {"w": round(box.get("width", 0)), "h": round(box.get("height", 0))},
|
|
101
|
+
"nodes": visible_text_nodes(document),
|
|
102
|
+
})
|
|
103
|
+
if not pages:
|
|
104
|
+
sys.exit("none of the frames could be rendered")
|
|
184
105
|
return pages
|
|
185
106
|
|
|
186
107
|
|
|
187
|
-
def
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
108
|
+
def assign(rows, pages):
|
|
109
|
+
index = ov.build_index(rows)
|
|
110
|
+
hits = {}
|
|
111
|
+
for page in pages:
|
|
112
|
+
ordered = sorted(page.get("nodes") or [], key=lambda item: float(item.get("y", 0)))
|
|
113
|
+
for node in ordered:
|
|
114
|
+
position = ov.match_row(node, index)
|
|
115
|
+
if position is not None:
|
|
116
|
+
hits.setdefault(position, (page, node))
|
|
117
|
+
return hits
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def crop_html(page, node, pad):
|
|
121
|
+
fw, fh = (float(page["frame"][side]) for side in ("w", "h"))
|
|
122
|
+
x, y, w, h = (float(node.get(axis, 0)) for axis in ("x", "y", "w", "h"))
|
|
123
|
+
top = max(0.0, y - pad)
|
|
124
|
+
height = max(1.0, min(fh, y + h + pad) - top)
|
|
125
|
+
pin_left = "left:0;"
|
|
126
|
+
box_class = "box"
|
|
127
|
+
style = (
|
|
128
|
+
f"html,body{{margin:0;padding:0;}}body{{width:{fw:.0f}px;height:{height:.0f}px;position:relative;overflow:hidden;}}"
|
|
129
|
+
f"img{{position:absolute;{pin_left}top:{-top:.0f}px;width:{fw:.0f}px;height:{fh:.0f}px;}}"
|
|
130
|
+
f".{box_class}{{position:absolute;left:{x - INSET:.0f}px;top:{y - top - INSET:.0f}px;width:{w + 2 * INSET:.0f}px;"
|
|
131
|
+
f"height:{h + 2 * INSET:.0f}px;border:3px solid {BOX_COLOUR};border-radius:6px;box-sizing:border-box;}}"
|
|
132
|
+
)
|
|
133
|
+
document = (
|
|
134
|
+
f"{ov.PAGE_OPEN}{ov.META_UTF8}<style>{style}</style></head>"
|
|
135
|
+
f'<body><img src="{page["image"]}"><div class="{box_class}"></div></body></html>'
|
|
136
|
+
)
|
|
137
|
+
return document, fw, height
|
|
203
138
|
|
|
204
|
-
with open(args.mapping, encoding="utf-8") as fh:
|
|
205
|
-
m = json.load(fh)
|
|
206
|
-
rows = m.get("rows") or []
|
|
207
|
-
screen = m.get("screen") or "screen"
|
|
208
|
-
sl = args.slug or slug(screen)
|
|
209
|
-
shots_dir = os.path.join(args.out, "keyshots")
|
|
210
|
-
os.makedirs(shots_dir, exist_ok=True)
|
|
211
139
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
140
|
+
def main():
|
|
141
|
+
cli = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
142
|
+
cli.add_argument("--mapping", required=True, help="the reuse-map JSON")
|
|
143
|
+
cli.add_argument("--spec", action="append", help="geometry spec for one screen state; may be repeated")
|
|
144
|
+
cli.add_argument("--file", help="Figma file key; falls back to figmaFileKey in the mapping")
|
|
145
|
+
cli.add_argument("--nodes", help="frame node-ids separated by commas; falls back to figmaNodes")
|
|
146
|
+
cli.add_argument("--pad", default=150, type=int, help="pixels of surrounding context kept over and under the element")
|
|
147
|
+
cli.add_argument("--scale", default=2, type=int)
|
|
148
|
+
cli.add_argument("--token", help="Figma token; FIGMA_ACCESS_TOKEN or the keychain is safer since other processes can read argv")
|
|
149
|
+
cli.add_argument("--ca-file", help="PEM roots to trust when a proxy intercepts TLS")
|
|
150
|
+
cli.add_argument("--out", dest="target_dir", default=".", help="folder for the manifest; the crops go into <out>/keyshots/")
|
|
151
|
+
cli.add_argument("--slug", help="stem of the manifest name; derived from the screen name when omitted")
|
|
152
|
+
opts = cli.parse_args()
|
|
153
|
+
|
|
154
|
+
with open(opts.mapping, encoding="utf-8") as stream:
|
|
155
|
+
mapping = json.load(stream)
|
|
156
|
+
rows = mapping.get("rows") or []
|
|
157
|
+
stem = opts.slug or ov.slug(mapping.get("screen"))
|
|
158
|
+
|
|
159
|
+
if opts.spec:
|
|
160
|
+
pages = spec_pages(opts.spec)
|
|
223
161
|
else:
|
|
224
|
-
file_key =
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
"Or pass --spec for the offline path.")
|
|
230
|
-
token = figma_token(args)
|
|
162
|
+
file_key = opts.file or mapping.get("figmaFileKey")
|
|
163
|
+
nodes = ov.split_nodes(opts.nodes, mapping)
|
|
164
|
+
if not (file_key and nodes):
|
|
165
|
+
cli.error("need a Figma file key and frame node-ids (flags or mapping), or --spec")
|
|
166
|
+
token = ov.figma_token(opts.token)
|
|
231
167
|
if not token:
|
|
232
|
-
sys.exit("No Figma token
|
|
233
|
-
"
|
|
234
|
-
pages =
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
for
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
168
|
+
sys.exit("No Figma token found (FIGMA_ACCESS_TOKEN, FIGMA_TOKEN, keychain FIGMA_ACCESS_TOKEN or --token). "
|
|
169
|
+
"Without one, build --spec files from the screenshots instead.")
|
|
170
|
+
pages = rest_pages(file_key, nodes, opts.scale, token, opts.ca_file)
|
|
171
|
+
|
|
172
|
+
crops_dir = os.path.join(opts.target_dir, "keyshots")
|
|
173
|
+
os.makedirs(crops_dir, exist_ok=True)
|
|
174
|
+
chrome = ov.find_chrome()
|
|
175
|
+
hits = assign(rows, pages)
|
|
176
|
+
shots, missing = [], []
|
|
177
|
+
failed = 0
|
|
178
|
+
|
|
179
|
+
for number, row in enumerate(rows, 1):
|
|
180
|
+
key = str(row.get("newKey") or "").strip()
|
|
181
|
+
if not key:
|
|
182
|
+
continue
|
|
183
|
+
if number - 1 not in hits:
|
|
184
|
+
missing.append({"row": number, "key": key, "reason": f"no visible node matched ({ID_FIELD}/value)"})
|
|
185
|
+
continue
|
|
186
|
+
page, node = hits[number - 1]
|
|
187
|
+
document, width, height = crop_html(page, node, opts.pad)
|
|
188
|
+
base = shot_name(number, key)
|
|
189
|
+
entry = {
|
|
190
|
+
"row": number,
|
|
191
|
+
"key": TRAILING_MARKER.sub("", key),
|
|
192
|
+
"element": row.get("element"),
|
|
193
|
+
"pageNode": page.get("nodeId"),
|
|
194
|
+
"cropW": int(round(width)),
|
|
195
|
+
"cropH": int(round(height)),
|
|
196
|
+
}
|
|
197
|
+
if not chrome:
|
|
198
|
+
with open(os.path.join(crops_dir, base + ".html"), "w", encoding="utf-8") as sink:
|
|
199
|
+
sink.write(document)
|
|
200
|
+
entry.update(file=f"keyshots/{base}.html", fallback="html")
|
|
201
|
+
shots.append(entry)
|
|
202
|
+
continue
|
|
203
|
+
try:
|
|
204
|
+
ov.chrome_screenshot(chrome, document, os.path.join(crops_dir, base + ".png"), width, height, opts.scale)
|
|
205
|
+
except (RuntimeError, subprocess.SubprocessError) as exc:
|
|
206
|
+
failed += 1
|
|
207
|
+
print(f"# row {number} {key}: {exc}", file=sys.stderr)
|
|
208
|
+
continue
|
|
209
|
+
entry["file"] = f"keyshots/{base}.png"
|
|
210
|
+
shots.append(entry)
|
|
211
|
+
|
|
212
|
+
manifest_name = f"{stem}.keyshots.manifest.json"
|
|
213
|
+
manifest = {
|
|
214
|
+
"screen": mapping.get("screen"),
|
|
215
|
+
"slug": stem,
|
|
216
|
+
"pad": opts.pad,
|
|
217
|
+
"scale": opts.scale,
|
|
218
|
+
"dir": "keyshots",
|
|
219
|
+
"shots": shots,
|
|
220
|
+
"missing": missing,
|
|
221
|
+
}
|
|
222
|
+
with open(os.path.join(opts.target_dir, manifest_name), "w", encoding="utf-8") as sink:
|
|
223
|
+
json.dump(manifest, sink, indent=2, ensure_ascii=False)
|
|
224
|
+
sink.write("\n")
|
|
253
225
|
|
|
254
|
-
chrome = find_chrome()
|
|
255
226
|
if not chrome:
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
"dir": "keyshots", "shots": [], "missing": missing}
|
|
260
|
-
ok = fail = 0
|
|
261
|
-
for i in sorted(chosen):
|
|
262
|
-
r, c = rows[i], chosen[i]
|
|
263
|
-
page, n = c["page"], c["node"]
|
|
264
|
-
num = i + 1
|
|
265
|
-
html, w, h = build_shot_html(page["_img_src"], page["frame"]["w"], page["frame"]["h"],
|
|
266
|
-
{"x": n["x"], "y": n["y"], "w": n["w"], "h": n["h"]}, args.pad)
|
|
267
|
-
fname = key_file(num, r["newKey"])
|
|
268
|
-
out_png = os.path.join(shots_dir, fname)
|
|
269
|
-
entry = {"row": num, "key": r["newKey"], "element": r.get("element"),
|
|
270
|
-
"file": f"keyshots/{fname}", "pageNode": page.get("nodeId"),
|
|
271
|
-
"cropW": w, "cropH": h}
|
|
272
|
-
if chrome:
|
|
273
|
-
err = render_shot_png(chrome, html, w, h, out_png, args.scale)
|
|
274
|
-
if err:
|
|
275
|
-
fail += 1
|
|
276
|
-
sys.stderr.write(f"# ✗ row {num} {r['newKey']}: {err}\n")
|
|
277
|
-
continue
|
|
278
|
-
else:
|
|
279
|
-
alt = out_png[:-4] + ".html"
|
|
280
|
-
with open(alt, "w", encoding="utf-8") as fh:
|
|
281
|
-
fh.write(html)
|
|
282
|
-
entry["file"] = f"keyshots/{os.path.basename(alt)}"
|
|
283
|
-
entry["fallback"] = "html"
|
|
284
|
-
ok += 1
|
|
285
|
-
manifest["shots"].append(entry)
|
|
286
|
-
|
|
287
|
-
mpath = os.path.join(args.out, sl + ".keyshots.manifest.json")
|
|
288
|
-
with open(mpath, "w", encoding="utf-8") as fh:
|
|
289
|
-
json.dump(manifest, fh, ensure_ascii=False, indent=2)
|
|
290
|
-
fh.write("\n")
|
|
291
|
-
sys.stderr.write(f"# keyshots: {ok} written, {len(missing)} unmatched, {fail} failed → "
|
|
292
|
-
f"{shots_dir}/ · manifest: {mpath}\n")
|
|
293
|
-
sys.stderr.write(f'# set the mapping\'s top-level "keyshots": "{os.path.basename(mpath)}" '
|
|
294
|
-
"so build-artifact.py fills the table cells\n")
|
|
227
|
+
print("# Chrome, Chromium and Edge are all missing; the crops were saved as .html", file=sys.stderr)
|
|
228
|
+
print(f"# keyshots: {len(shots)} written, {len(missing)} unmatched, {failed} failed", file=sys.stderr)
|
|
229
|
+
print(f'# point the mapping\'s top-level "keyshots" field at "{manifest_name}"', file=sys.stderr)
|
|
295
230
|
|
|
296
231
|
|
|
297
232
|
if __name__ == "__main__":
|