@topy-ai/maggie 0.7.48 → 0.7.49
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/README-zh-TW.md +2 -2
- package/README.md +31 -4
- package/bin/maggie.js +17 -4
- package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
- package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
- package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
- package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
- package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
- package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
- package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
- package/bundled-references/google-integrations-runbook.md +54 -0
- package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
- package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
- package/bundled-skills/maggie-deployment/SKILL.md +89 -7
- package/bundled-skills/maggie-deployment/references/vps.md +38 -6
- package/bundled-skills/maggie-design/SKILL.md +55 -0
- package/bundled-skills/maggie-feedback/SKILL.md +29 -0
- package/bundled-skills/maggie-ops/SKILL.md +37 -0
- package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +59 -0
- package/bundled-skills/maggie-social-share/SKILL.md +19 -2
- package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +51 -0
- package/bundled-tools/clis/maggie_deployment.py +58 -17
- package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
- package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
- package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
- package/bundled-tools/clis/maggie_design.py +8 -0
- package/bundled-tools/clis/maggie_design_verify.py +359 -0
- package/bundled-tools/clis/maggie_docs.py +82 -4
- package/bundled-tools/clis/maggie_email_localization.py +382 -0
- package/bundled-tools/clis/maggie_feedback.py +9 -2
- package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
- package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
- package/bundled-tools/clis/maggie_localization.py +5 -0
- package/bundled-tools/clis/maggie_marketing_run.py +272 -0
- package/bundled-tools/clis/maggie_notifications.py +528 -0
- package/bundled-tools/clis/maggie_ops.py +87 -0
- package/bundled-tools/clis/maggie_schedule.py +12 -2
- package/bundled-tools/clis/maggie_service_import.py +520 -0
- package/bundled-tools/clis/maggie_social_queue.py +104 -0
- package/bundled-tools/clis/maggie_social_share.py +6 -2
- package/bundled-tools/runtime/google_ads.py +306 -0
- package/bundled-tools/runtime/google_capabilities.py +18 -2
- package/bundled-tools/runtime/google_remediation.py +152 -0
- package/package.json +1 -1
- package/references/google-integrations-runbook.md +54 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Collect rendered icon-glyph evidence in headless Chrome.
|
|
3
|
+
|
|
4
|
+
Source-to-runtime inventory proves names, not pixels. This collector renders
|
|
5
|
+
representative icons on each route and viewport and records, per icon:
|
|
6
|
+
|
|
7
|
+
- whether its icon font is in ``document.fonts`` with status ``loaded``;
|
|
8
|
+
- whether the glyph paints ink that differs from the browser fallback font
|
|
9
|
+
(a missing glyph falls back to another font or a blank box);
|
|
10
|
+
- whether runtime CSS hides it (``display``, ``visibility``, ``opacity``,
|
|
11
|
+
zero size, or a ``content: none`` pseudo-element);
|
|
12
|
+
- its family, accessible name, and the HTTP status of the icon font assets.
|
|
13
|
+
|
|
14
|
+
The target is either a running host (``--base-url``, proxied through a local
|
|
15
|
+
read-only GET proxy so the probe and fonts share one origin) or a static build
|
|
16
|
+
(``--dist``). Only GET requests are forwarded; the report never stores page
|
|
17
|
+
text beyond icon names and accessible labels.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import html as html_lib
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import threading
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from urllib.error import HTTPError, URLError
|
|
34
|
+
from urllib.parse import urlsplit
|
|
35
|
+
from urllib.request import Request, urlopen
|
|
36
|
+
|
|
37
|
+
SCHEMA = "maggie-icon-browser-evidence.v1"
|
|
38
|
+
COLLECTOR = "maggie-icon-glyph-check"
|
|
39
|
+
DEFAULT_VIEWPORTS = {"mobile": (390, 844), "tablet": (768, 1024), "desktop": (1440, 900)}
|
|
40
|
+
FONT_EXTENSIONS = (".woff2", ".woff", ".ttf", ".otf", ".eot")
|
|
41
|
+
REPORT_ID = "maggie-icon-probe-report"
|
|
42
|
+
DEFAULT_SELECTOR = (
|
|
43
|
+
"i[class*='ph'], span[class*='ph-'], [class^='ph-'], [class*=' ph-'], i[class*='fa-'], span[class*='fa-'], "
|
|
44
|
+
"[class*='lucide'], [data-icon], [data-glyph], .material-icons, .material-symbols-outlined, "
|
|
45
|
+
".material-symbols-rounded, .bi, [class*='bi-']"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
PROBE = r"""
|
|
49
|
+
(() => {
|
|
50
|
+
const config = __CONFIG__;
|
|
51
|
+
const out = document.createElement('pre');
|
|
52
|
+
out.id = config.reportId; out.hidden = true;
|
|
53
|
+
const norm = (value) => String(value || '').split(',')[0].trim().replace(/^['"]|['"]$/g, '').toLowerCase();
|
|
54
|
+
const nameOf = (el) => {
|
|
55
|
+
const attr = el.getAttribute('data-icon') || el.getAttribute('data-glyph');
|
|
56
|
+
if (attr) return attr;
|
|
57
|
+
for (const token of Array.from(el.classList)) {
|
|
58
|
+
const match = token.match(/^(?:ph|fa|lucide|bi)-([a-z0-9-]+)$/i);
|
|
59
|
+
if (match && !/^(?:thin|light|regular|bold|fill|duotone|solid|brands|fw|lg|xl)$/i.test(match[1])) return match[1];
|
|
60
|
+
}
|
|
61
|
+
const text = (el.textContent || '').trim();
|
|
62
|
+
if (/material/.test(el.className) && text) return text;
|
|
63
|
+
return el.tagName.toLowerCase() === 'svg' ? (el.getAttribute('data-lucide') || 'svg') : (el.className || el.tagName).toString().slice(0, 60);
|
|
64
|
+
};
|
|
65
|
+
const hiddenBy = (el) => {
|
|
66
|
+
for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
|
|
67
|
+
const style = getComputedStyle(node);
|
|
68
|
+
if (style.display === 'none') return node === el ? 'display:none' : 'ancestor display:none';
|
|
69
|
+
if (style.visibility === 'hidden' || style.visibility === 'collapse') return 'visibility:' + style.visibility;
|
|
70
|
+
if (Number(style.opacity) === 0) return node === el ? 'opacity:0' : 'ancestor opacity:0';
|
|
71
|
+
if (node === document.body) break;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
};
|
|
75
|
+
const accessibleName = (el) => {
|
|
76
|
+
const direct = el.getAttribute('aria-label') || el.getAttribute('title');
|
|
77
|
+
if (direct && direct.trim()) return { name: direct.trim(), decorative: false };
|
|
78
|
+
const labelledby = el.getAttribute('aria-labelledby');
|
|
79
|
+
if (labelledby) {
|
|
80
|
+
const text = labelledby.split(/\s+/).map((id) => document.getElementById(id)?.textContent || '').join(' ').trim();
|
|
81
|
+
if (text) return { name: text, decorative: false };
|
|
82
|
+
}
|
|
83
|
+
const control = el.closest('a,button,[role=button],[role=link],label,summary');
|
|
84
|
+
if (control) {
|
|
85
|
+
const label = control.getAttribute('aria-label') || control.getAttribute('title') || (control.textContent || '').replace(/\s+/g, ' ').trim();
|
|
86
|
+
if (label) return { name: label.slice(0, 120), decorative: el.getAttribute('aria-hidden') === 'true' };
|
|
87
|
+
}
|
|
88
|
+
const parentText = (el.parentElement?.textContent || '').replace(/\s+/g, ' ').trim();
|
|
89
|
+
return { name: parentText.slice(0, 120), decorative: true };
|
|
90
|
+
};
|
|
91
|
+
const pixels = (font, text) => {
|
|
92
|
+
const canvas = document.createElement('canvas'); canvas.width = 64; canvas.height = 64;
|
|
93
|
+
const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, 64, 64); ctx.fillStyle = '#000';
|
|
94
|
+
ctx.textBaseline = 'middle'; ctx.font = font; ctx.fillText(text, 8, 32);
|
|
95
|
+
const data = ctx.getImageData(0, 0, 64, 64).data; let ink = 0; let hash = 0;
|
|
96
|
+
for (let i = 3; i < data.length; i += 4) { if (data[i]) ink += 1; hash = (hash * 31 + data[i]) | 0; }
|
|
97
|
+
return { ink, hash };
|
|
98
|
+
};
|
|
99
|
+
const glyphText = (el) => {
|
|
100
|
+
for (const pseudo of ['::before', '::after']) {
|
|
101
|
+
const style = getComputedStyle(el, pseudo);
|
|
102
|
+
const content = style.content;
|
|
103
|
+
if (content && content !== 'none' && content !== 'normal' && content !== '""') {
|
|
104
|
+
return { text: content.replace(/^["']|["']$/g, ''), style, pseudo };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const text = (el.textContent || '').trim();
|
|
108
|
+
return text ? { text, style: getComputedStyle(el), pseudo: null } : null;
|
|
109
|
+
};
|
|
110
|
+
const run = () => {
|
|
111
|
+
const icons = []; const seen = new Set();
|
|
112
|
+
for (const el of Array.from(document.querySelectorAll(config.selector))) {
|
|
113
|
+
const name = nameOf(el);
|
|
114
|
+
if (seen.has(name) || icons.length >= config.maxIcons) continue;
|
|
115
|
+
seen.add(name);
|
|
116
|
+
const rect = el.getBoundingClientRect();
|
|
117
|
+
const hidden = hiddenBy(el);
|
|
118
|
+
const label = accessibleName(el);
|
|
119
|
+
const record = { name, tag: el.tagName.toLowerCase(), width: Math.round(rect.width * 100) / 100, height: Math.round(rect.height * 100) / 100, hiddenBy: hidden, accessibleName: label.name, decorative: label.decorative };
|
|
120
|
+
if (el.tagName.toLowerCase() === 'svg' || el.querySelector('svg')) {
|
|
121
|
+
const svg = el.tagName.toLowerCase() === 'svg' ? el : el.querySelector('svg');
|
|
122
|
+
const box = svg.getBoundingClientRect();
|
|
123
|
+
record.kind = 'svg';
|
|
124
|
+
record.glyphPainted = box.width > 0 && box.height > 0 && !!svg.querySelector('path,circle,rect,line,polyline,polygon,use,ellipse');
|
|
125
|
+
record.fontLoaded = null; record.fontFamily = null;
|
|
126
|
+
} else if (el.tagName.toLowerCase() === 'img') {
|
|
127
|
+
record.kind = 'img';
|
|
128
|
+
record.glyphPainted = el.complete && el.naturalWidth > 0;
|
|
129
|
+
record.fontLoaded = null; record.fontFamily = null;
|
|
130
|
+
} else {
|
|
131
|
+
const glyph = glyphText(el);
|
|
132
|
+
record.kind = 'font';
|
|
133
|
+
if (!glyph) {
|
|
134
|
+
record.fontFamily = norm(getComputedStyle(el).fontFamily); record.fontLoaded = false; record.glyphPainted = false; record.hiddenBy = hidden || 'no glyph content';
|
|
135
|
+
} else {
|
|
136
|
+
const rawFamily = String(glyph.style.fontFamily || '').split(',')[0].trim().replace(/^['"]|['"]$/g, '');
|
|
137
|
+
const family = norm(rawFamily);
|
|
138
|
+
const weight = glyph.style.fontWeight || '400';
|
|
139
|
+
const faces = Array.from(document.fonts).filter((face) => norm(face.family) === family);
|
|
140
|
+
record.fontFamily = family;
|
|
141
|
+
record.fontLoaded = faces.some((face) => face.status === 'loaded');
|
|
142
|
+
record.fontStatus = faces.map((face) => face.status);
|
|
143
|
+
const icon = pixels(`${weight} 32px "${rawFamily}"`, glyph.text);
|
|
144
|
+
const fallback = pixels(`${weight} 32px "maggie-missing-font-probe"`, glyph.text);
|
|
145
|
+
record.glyphPainted = icon.ink > 0 && (icon.hash !== fallback.hash || icon.ink !== fallback.ink);
|
|
146
|
+
record.ink = icon.ink; record.fallbackInk = fallback.ink; record.glyphText = glyph.text.length <= 4 ? Array.from(glyph.text).map((c) => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' ') : glyph.text.slice(0, 40);
|
|
147
|
+
if (glyph.pseudo && getComputedStyle(el, glyph.pseudo).display === 'none' && !record.hiddenBy) record.hiddenBy = glyph.pseudo + ' display:none';
|
|
148
|
+
}
|
|
149
|
+
const prefix = Object.keys(config.families).find((key) => (el.className || '').toString().split(/\s+/).some((token) => token === key || token.startsWith(key + '-')));
|
|
150
|
+
if (prefix) record.familyMatches = record.fontFamily === config.families[prefix].toLowerCase();
|
|
151
|
+
}
|
|
152
|
+
record.visible = !record.hiddenBy && record.width > 0 && record.height > 0;
|
|
153
|
+
icons.push(record);
|
|
154
|
+
}
|
|
155
|
+
out.textContent = JSON.stringify({ icons, fontsReady: document.fonts.status, faces: Array.from(document.fonts).map((face) => ({ family: norm(face.family), status: face.status })) });
|
|
156
|
+
document.body.appendChild(out);
|
|
157
|
+
};
|
|
158
|
+
const start = () => document.fonts.ready.then(() => setTimeout(run, config.settleMs));
|
|
159
|
+
if (document.readyState === 'complete') start(); else window.addEventListener('load', start);
|
|
160
|
+
})();
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _probe_script(selector: str, max_icons: int, families: dict[str, str], settle_ms: int) -> str:
|
|
165
|
+
config = {"reportId": REPORT_ID, "selector": selector, "maxIcons": max_icons, "families": families, "settleMs": settle_ms}
|
|
166
|
+
return "<script>" + PROBE.replace("__CONFIG__", json.dumps(config)) + "</script>"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def inject(document: str, probe: str) -> str:
|
|
170
|
+
index = document.lower().rfind("</body>")
|
|
171
|
+
return document[:index] + probe + document[index:] if index >= 0 else document + probe
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class _ProbeServer:
|
|
175
|
+
"""Serve a static build or proxy GET requests to a host, injecting the probe."""
|
|
176
|
+
|
|
177
|
+
def __init__(self, probe: str, base_url: str | None = None, dist: Path | None = None) -> None:
|
|
178
|
+
self.probe = probe; self.base_url = base_url.rstrip("/") if base_url else None; self.dist = dist.resolve() if dist else None
|
|
179
|
+
self.assets: list[dict] = []; self.lock = threading.Lock()
|
|
180
|
+
server = self
|
|
181
|
+
|
|
182
|
+
class Handler(BaseHTTPRequestHandler):
|
|
183
|
+
def log_message(self, *_args) -> None: # silence request logging
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
def do_GET(self) -> None: # noqa: N802 - stdlib handler name
|
|
187
|
+
status, content_type, body = server.fetch(self.path)
|
|
188
|
+
if status < 400 and "text/html" in content_type:
|
|
189
|
+
body = inject(body.decode("utf-8", errors="replace"), server.probe).encode("utf-8")
|
|
190
|
+
path = urlsplit(self.path).path
|
|
191
|
+
if path.lower().endswith(FONT_EXTENSIONS) or "font" in content_type:
|
|
192
|
+
with server.lock:
|
|
193
|
+
server.assets.append({"path": path, "status": status, "contentType": content_type.split(";")[0]})
|
|
194
|
+
self.send_response(status)
|
|
195
|
+
self.send_header("Content-Type", content_type)
|
|
196
|
+
self.send_header("Content-Length", str(len(body)))
|
|
197
|
+
self.end_headers()
|
|
198
|
+
self.wfile.write(body)
|
|
199
|
+
|
|
200
|
+
def do_POST(self) -> None: # noqa: N802 - writes are never forwarded
|
|
201
|
+
self.send_response(405); self.send_header("Content-Length", "0"); self.end_headers()
|
|
202
|
+
|
|
203
|
+
do_PUT = do_PATCH = do_DELETE = do_POST
|
|
204
|
+
|
|
205
|
+
self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
206
|
+
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
|
|
207
|
+
|
|
208
|
+
def fetch(self, raw_path: str) -> tuple[int, str, bytes]:
|
|
209
|
+
path = urlsplit(raw_path).path or "/"
|
|
210
|
+
if self.dist is not None:
|
|
211
|
+
relative = path.lstrip("/")
|
|
212
|
+
target = (self.dist / relative).resolve()
|
|
213
|
+
if not target.is_relative_to(self.dist):
|
|
214
|
+
return 403, "text/plain", b"forbidden"
|
|
215
|
+
if target.is_dir():
|
|
216
|
+
target = target / "index.html"
|
|
217
|
+
elif not target.exists() and (self.dist / (relative + ".html")).exists():
|
|
218
|
+
target = self.dist / (relative + ".html")
|
|
219
|
+
if not target.is_file():
|
|
220
|
+
return 404, "text/plain", b"not found"
|
|
221
|
+
return 200, _content_type(target.name), target.read_bytes()
|
|
222
|
+
request = Request(self.base_url + raw_path, headers={"Accept-Encoding": "identity", "User-Agent": "maggie-icon-glyph-check/1"})
|
|
223
|
+
try:
|
|
224
|
+
with urlopen(request, timeout=30) as response:
|
|
225
|
+
return response.status, response.headers.get("Content-Type", "application/octet-stream"), response.read()
|
|
226
|
+
except HTTPError as error:
|
|
227
|
+
return error.code, error.headers.get("Content-Type", "text/plain") if error.headers else "text/plain", error.read() or b""
|
|
228
|
+
except URLError as error:
|
|
229
|
+
return 502, "text/plain", f"upstream unavailable: {error.reason}".encode()
|
|
230
|
+
|
|
231
|
+
def __enter__(self) -> "_ProbeServer":
|
|
232
|
+
self.thread.start(); return self
|
|
233
|
+
|
|
234
|
+
def __exit__(self, *_exc) -> None:
|
|
235
|
+
self.httpd.shutdown(); self.httpd.server_close()
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def origin(self) -> str:
|
|
239
|
+
return f"http://127.0.0.1:{self.httpd.server_port}"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _content_type(name: str) -> str:
|
|
243
|
+
lower = name.lower()
|
|
244
|
+
for suffix, value in ((".html", "text/html; charset=utf-8"), (".css", "text/css"), (".js", "text/javascript"), (".mjs", "text/javascript"), (".svg", "image/svg+xml"), (".png", "image/png"), (".jpg", "image/jpeg"), (".webp", "image/webp"), (".json", "application/json"), (".woff2", "font/woff2"), (".woff", "font/woff"), (".ttf", "font/ttf"), (".otf", "font/otf")):
|
|
245
|
+
if lower.endswith(suffix):
|
|
246
|
+
return value
|
|
247
|
+
return "application/octet-stream"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def find_chrome() -> str:
|
|
251
|
+
chrome = shutil.which("google-chrome") or shutil.which("chromium") or shutil.which("chromium-browser") or shutil.which("chrome")
|
|
252
|
+
if not chrome:
|
|
253
|
+
raise RuntimeError("google-chrome or chromium is required for icon-glyph-check")
|
|
254
|
+
return chrome
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def render(chrome: str, url: str, width: int, height: int, budget_ms: int) -> dict:
|
|
258
|
+
proc = subprocess.run([chrome, "--headless=new", "--no-sandbox", "--disable-gpu", "--hide-scrollbars", f"--window-size={width},{height}", f"--virtual-time-budget={budget_ms}", "--dump-dom", url], capture_output=True, text=True, timeout=180, check=False)
|
|
259
|
+
if proc.returncode != 0:
|
|
260
|
+
raise RuntimeError(f"headless chrome failed: {(proc.stderr or '')[-300:]}")
|
|
261
|
+
match = re.search(rf'<pre id="{REPORT_ID}"[^>]*>(.*?)</pre>', proc.stdout, re.S)
|
|
262
|
+
if not match:
|
|
263
|
+
raise RuntimeError("icon probe did not report; the page may block inline scripts or never finished loading")
|
|
264
|
+
return json.loads(html_lib.unescape(match.group(1)) or "{}")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _asset_status(icon: dict, assets: list[dict]) -> int:
|
|
268
|
+
if icon.get("kind") != "font":
|
|
269
|
+
return 200 if icon.get("glyphPainted") else 0
|
|
270
|
+
if icon.get("fontLoaded"):
|
|
271
|
+
return 200 # the face reached status "loaded", so its file was served
|
|
272
|
+
failing = [item["status"] for item in assets if item["status"] >= 400]
|
|
273
|
+
return min(failing) if failing else 0
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def collect(routes: list[str], viewports: dict[str, tuple[int, int]], base_url: str | None, dist: Path | None, selector: str, families: dict[str, str], max_icons: int, budget_ms: int, settle_ms: int) -> dict:
|
|
277
|
+
if bool(base_url) == bool(dist):
|
|
278
|
+
raise ValueError("exactly one of --base-url or --dist is required")
|
|
279
|
+
chrome = find_chrome()
|
|
280
|
+
probe = _probe_script(selector, max_icons, families, settle_ms)
|
|
281
|
+
icons: list[dict] = []; errors: list[str] = []; route_results: list[dict] = []
|
|
282
|
+
with _ProbeServer(probe, base_url=base_url, dist=dist) as server:
|
|
283
|
+
for route in routes:
|
|
284
|
+
if not route.startswith("/"):
|
|
285
|
+
raise ValueError(f"route must start with /: {route}")
|
|
286
|
+
for viewport, (width, height) in viewports.items():
|
|
287
|
+
before = len(server.assets)
|
|
288
|
+
try:
|
|
289
|
+
report = render(chrome, server.origin + route, width, height, budget_ms)
|
|
290
|
+
except RuntimeError as error:
|
|
291
|
+
errors.append(f"{route} @ {viewport}: {error}")
|
|
292
|
+
route_results.append({"route": route, "viewport": viewport, "status": "error"})
|
|
293
|
+
continue
|
|
294
|
+
assets = server.assets[before:] or server.assets
|
|
295
|
+
found = report.get("icons") or []
|
|
296
|
+
for icon in found:
|
|
297
|
+
icon.update({"route": route, "viewport": viewport, "assetStatus": _asset_status(icon, assets)})
|
|
298
|
+
icons.append(icon)
|
|
299
|
+
route_results.append({"route": route, "viewport": viewport, "status": "rendered", "iconCount": len(found), "fontsReady": report.get("fontsReady")})
|
|
300
|
+
if not found:
|
|
301
|
+
errors.append(f"{route} @ {viewport}: no icons matched the selector")
|
|
302
|
+
font_assets = sorted({(item["path"], item["status"]) for item in server.assets})
|
|
303
|
+
failures = sorted({f"{icon['name']} @ {icon['route']} {icon['viewport']}" for icon in icons if icon_failure(icon)})
|
|
304
|
+
if failures:
|
|
305
|
+
errors.append("icons failed rendered-glyph checks: " + ", ".join(failures[:20]))
|
|
306
|
+
return {
|
|
307
|
+
"schemaVersion": SCHEMA, "collector": COLLECTOR, "passed": not errors,
|
|
308
|
+
"target": "dist" if dist else "base-url", "routes": routes, "viewports": {name: list(size) for name, size in viewports.items()},
|
|
309
|
+
"renders": route_results, "icons": icons,
|
|
310
|
+
"fontAssets": [{"path": path, "status": status} for path, status in font_assets],
|
|
311
|
+
"errors": errors, "generatedAt": datetime.now(timezone.utc).isoformat(), "mutation": "not executed",
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def icon_failure(icon: dict) -> str | None:
|
|
316
|
+
"""Return why one collected icon fails, or ``None`` when it is proven painted."""
|
|
317
|
+
if icon.get("hiddenBy"):
|
|
318
|
+
return f"hidden by {icon['hiddenBy']}"
|
|
319
|
+
if not (isinstance(icon.get("width"), (int, float)) and icon["width"] > 0 and isinstance(icon.get("height"), (int, float)) and icon["height"] > 0):
|
|
320
|
+
return "zero size"
|
|
321
|
+
if icon.get("kind") == "font" and icon.get("fontLoaded") is not True:
|
|
322
|
+
return "icon font not loaded"
|
|
323
|
+
if icon.get("glyphPainted") is not True:
|
|
324
|
+
return "glyph not painted (fallback font or blank)"
|
|
325
|
+
if icon.get("familyMatches") is False:
|
|
326
|
+
return "unexpected font family"
|
|
327
|
+
if not str(icon.get("accessibleName") or "").strip():
|
|
328
|
+
return "no accessible name"
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _parse_viewports(values: list[str]) -> dict[str, tuple[int, int]]:
|
|
333
|
+
if not values:
|
|
334
|
+
return dict(DEFAULT_VIEWPORTS)
|
|
335
|
+
result: dict[str, tuple[int, int]] = {}
|
|
336
|
+
for value in values:
|
|
337
|
+
match = re.fullmatch(r"(?:([a-z0-9-]+)=)?(\d{3,4})x(\d{3,4})", value.strip(), re.I)
|
|
338
|
+
if not match:
|
|
339
|
+
raise ValueError(f"viewport must look like mobile=390x844 or 390x844: {value}")
|
|
340
|
+
name = match.group(1) or f"{match.group(2)}w"
|
|
341
|
+
result[name] = (int(match.group(2)), int(match.group(3)))
|
|
342
|
+
return result
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _parse_families(values: list[str]) -> dict[str, str]:
|
|
346
|
+
result = {}
|
|
347
|
+
for value in values:
|
|
348
|
+
prefix, sep, family = value.partition("=")
|
|
349
|
+
if not sep or not prefix.strip() or not family.strip():
|
|
350
|
+
raise ValueError(f"--family must look like ph=Phosphor: {value}")
|
|
351
|
+
result[prefix.strip()] = family.strip()
|
|
352
|
+
return result
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def main(argv: list[str] | None = None) -> int:
|
|
356
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
357
|
+
parser.add_argument("--base-url", help="running host origin, e.g. http://localhost:4321 (GET-only proxy)")
|
|
358
|
+
parser.add_argument("--dist", type=Path, help="static build directory to serve locally")
|
|
359
|
+
parser.add_argument("--route", action="append", default=[], help="route to render; repeatable (default /)")
|
|
360
|
+
parser.add_argument("--viewport", action="append", default=[], help="name=WIDTHxHEIGHT; repeatable (default mobile, tablet, desktop)")
|
|
361
|
+
parser.add_argument("--selector", default=DEFAULT_SELECTOR, help="CSS selector for icon elements")
|
|
362
|
+
parser.add_argument("--family", action="append", default=[], help="expected font family per class prefix, e.g. ph=Phosphor")
|
|
363
|
+
parser.add_argument("--max-icons", type=int, default=60, help="representative icons per render (one per name)")
|
|
364
|
+
parser.add_argument("--budget-ms", type=int, default=8000)
|
|
365
|
+
parser.add_argument("--settle-ms", type=int, default=250)
|
|
366
|
+
parser.add_argument("--output", type=Path, required=True)
|
|
367
|
+
args = parser.parse_args(argv)
|
|
368
|
+
try:
|
|
369
|
+
report = collect(args.route or ["/"], _parse_viewports(args.viewport), args.base_url, args.dist, args.selector, _parse_families(args.family), args.max_icons, args.budget_ms, args.settle_ms)
|
|
370
|
+
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as error:
|
|
371
|
+
print(f"BLOCKED: maggie-design icon-glyph-check: {error}", file=sys.stderr)
|
|
372
|
+
return 2
|
|
373
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
374
|
+
args.output.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
375
|
+
summary = {key: report[key] for key in ("schemaVersion", "collector", "passed", "routes", "errors")}
|
|
376
|
+
summary.update({"iconCount": len(report["icons"]), "output": str(args.output)})
|
|
377
|
+
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
|
378
|
+
return 0 if report["passed"] else 1
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
if __name__ == "__main__":
|
|
382
|
+
raise SystemExit(main())
|
|
@@ -5,9 +5,12 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
7
|
import json
|
|
8
|
+
import sys
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
13
|
+
|
|
11
14
|
|
|
12
15
|
def validate(icon_report: object, browser_report: object) -> dict[str, Any]:
|
|
13
16
|
errors: list[str] = []
|
|
@@ -40,6 +43,27 @@ def validate(icon_report: object, browser_report: object) -> dict[str, Any]:
|
|
|
40
43
|
invalid.append(name)
|
|
41
44
|
if invalid:
|
|
42
45
|
errors.append("browser icon evidence has invisible, zero-size, inaccessible, or unserved glyphs")
|
|
46
|
+
# Evidence from `maggie design icon-glyph-check` also proves the icon font
|
|
47
|
+
# loaded and the glyph differs from the fallback font at every viewport.
|
|
48
|
+
# Hand-written legacy evidence cannot prove that, so it is only warned.
|
|
49
|
+
warnings: list[str] = []
|
|
50
|
+
glyph_proof = browser_report.get("collector") == "maggie-icon-glyph-check" or any(isinstance(icon, dict) and "glyphPainted" in icon for icon in icons)
|
|
51
|
+
glyph_failures: list[str] = []
|
|
52
|
+
if glyph_proof:
|
|
53
|
+
from maggie_icon_glyph_check import icon_failure
|
|
54
|
+
|
|
55
|
+
for icon in icons:
|
|
56
|
+
reason = icon_failure(icon) if isinstance(icon, dict) else "invalid icon record"
|
|
57
|
+
if reason:
|
|
58
|
+
glyph_failures.append(f"{icon.get('name', '?') if isinstance(icon, dict) else '?'} @ {icon.get('viewport', '?') if isinstance(icon, dict) else '?'}: {reason}")
|
|
59
|
+
expected_viewports = set((browser_report.get("viewports") or {}).keys()) if isinstance(browser_report.get("viewports"), dict) else set()
|
|
60
|
+
rendered = {icon.get("viewport") for icon in icons if isinstance(icon, dict)}
|
|
61
|
+
if expected_viewports and expected_viewports - rendered:
|
|
62
|
+
glyph_failures.append("no icon evidence for viewport(s): " + ", ".join(sorted(expected_viewports - rendered)))
|
|
63
|
+
if glyph_failures:
|
|
64
|
+
errors.append("rendered glyph proof failed: font not loaded, fallback glyph, hidden by CSS, or missing viewport")
|
|
65
|
+
else:
|
|
66
|
+
warnings.append("browser evidence does not prove font loading or glyph identity; collect it with `maggie design icon-glyph-check`")
|
|
43
67
|
return {
|
|
44
68
|
"schemaVersion": "maggie-icon-release-gate.v1",
|
|
45
69
|
"passed": not errors,
|
|
@@ -47,9 +71,12 @@ def validate(icon_report: object, browser_report: object) -> dict[str, Any]:
|
|
|
47
71
|
"checks": {
|
|
48
72
|
"sourceRuntime": source_ok,
|
|
49
73
|
"servedAssets": browser_ok and not bool(invalid),
|
|
50
|
-
"paintedGlyphs": bool(icons) and not bool(invalid),
|
|
74
|
+
"paintedGlyphs": bool(icons) and not bool(invalid) and not glyph_failures,
|
|
51
75
|
"accessibility": bool(icons) and not bool(invalid),
|
|
52
76
|
},
|
|
77
|
+
"glyphIdentityProven": glyph_proof and not glyph_failures,
|
|
78
|
+
"glyphFailures": glyph_failures[:50],
|
|
79
|
+
"warnings": warnings,
|
|
53
80
|
"iconCount": len(icons),
|
|
54
81
|
"invalidIcons": sorted(set(invalid)),
|
|
55
82
|
"mutation": "not executed",
|
|
@@ -15,6 +15,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
|
15
15
|
from content_localization import LANGUAGES, MARKETS, OPERATIONS, parse_locale, protected_field_changes, stale, valid_locale, validate_operation_quality, validate_translation
|
|
16
16
|
from localization_runner import generate, process_adapter
|
|
17
17
|
|
|
18
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
19
|
+
import maggie_email_localization as email_localization
|
|
20
|
+
|
|
18
21
|
|
|
19
22
|
def generate_job(args: argparse.Namespace) -> int:
|
|
20
23
|
if not args.confirm:
|
|
@@ -395,6 +398,7 @@ def main() -> int:
|
|
|
395
398
|
publish_parser.add_argument("job", type=Path); publish_parser.add_argument("--confirm", action="store_true")
|
|
396
399
|
for name in ("stale", "glossary"):
|
|
397
400
|
command = sub.add_parser(name); command.add_argument("--project", type=Path, default=Path("."))
|
|
401
|
+
email_localization.add_parsers(sub)
|
|
398
402
|
args = parser.parse_args()
|
|
399
403
|
try:
|
|
400
404
|
if args.command == "generate": return generate_job(args)
|
|
@@ -406,6 +410,7 @@ def main() -> int:
|
|
|
406
410
|
if args.command == "review": return review(args.job, args.reviewer, args.decision)
|
|
407
411
|
if args.command == "publish": return publish(args.job, args.confirm)
|
|
408
412
|
if args.command == "stale": return stale_jobs(args.project)
|
|
413
|
+
if args.command in ("email-matrix", "lifecycle"): return email_localization.run(args)
|
|
409
414
|
return glossary(args.project)
|
|
410
415
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
411
416
|
print(f"ERROR: {error}", file=sys.stderr); return 1
|