@topy-ai/maggie 0.7.52 → 0.7.54
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 +28 -4
- package/README.md +93 -60
- package/bin/maggie.js +67 -12
- package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +161 -0
- package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +268 -20
- package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +49 -7
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +181 -13
- package/bundled-contracts/maggiedash/booking-rules-v1.json +1699 -0
- package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +398 -39
- package/bundled-references/maggiedash-booking/PRD.md +22 -11
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
- package/bundled-references/maggiedash-booking/RULES.md +167 -0
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
- package/bundled-references/provider-adapter-contract.md +16 -3
- package/bundled-skills/catalog.json +1 -1
- package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
- package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
- package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
- package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
- package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
- package/bundled-skills/maggie-blog/SKILL.md +8 -0
- package/bundled-skills/maggie-booking/SKILL.md +66 -14
- package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
- package/bundled-skills/maggie-dash/SKILL.md +28 -2
- package/bundled-skills/maggie-deployment/SKILL.md +48 -0
- package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
- package/bundled-skills/maggie-deployment/references/vps.md +92 -0
- package/bundled-skills/maggie-design/SKILL.md +98 -3
- package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
- package/bundled-skills/maggie-feedback/SKILL.md +27 -2
- package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
- package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
- package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
- package/bundled-skills/maggie-mobile-app/SKILL.md +65 -6
- package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
- package/bundled-skills/maggie-social-share/SKILL.md +56 -4
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
- package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
- package/bundled-tools/clis/maggie_booking.py +24 -12
- package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
- package/bundled-tools/clis/maggie_booking_rules.py +476 -0
- package/bundled-tools/clis/maggie_bridge_host.py +77 -1
- package/bundled-tools/clis/maggie_dash.py +173 -3
- package/bundled-tools/clis/maggie_deployment_ops.py +524 -0
- package/bundled-tools/clis/maggie_design.py +15 -2
- package/bundled-tools/clis/maggie_design_bulk.py +290 -0
- package/bundled-tools/clis/maggie_design_lint.py +205 -3
- package/bundled-tools/clis/maggie_feedback.py +138 -28
- package/bundled-tools/clis/maggie_live_urls.py +138 -0
- package/bundled-tools/clis/maggie_localization.py +3 -0
- package/bundled-tools/clis/maggie_localization_keys.py +151 -0
- package/bundled-tools/clis/maggie_media_generate.py +174 -0
- package/bundled-tools/clis/maggie_mobile.py +219 -4
- package/bundled-tools/clis/maggie_product_release.py +49 -1
- package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
- package/bundled-tools/clis/maggie_qa_fit.py +217 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
- package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
- package/bundled-tools/clis/maggie_seo_routes.py +154 -0
- package/bundled-tools/clis/maggie_service_migration.py +272 -0
- package/bundled-tools/clis/maggie_social_queue.py +2 -0
- package/bundled-tools/clis/maggie_social_share.py +174 -7
- package/bundled-tools/integrations/maggie-api-pull.md +22 -0
- package/bundled-tools/runtime/assistant_approval.py +98 -0
- package/package.json +1 -1
- package/references/provider-adapter-contract.md +16 -3
- package/bundled-contracts/maggiedash/execution-board.json +0 -5463
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
- package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
- package/bundled-references/maggiedash-booking/README.md +0 -288
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
- package/bundled-references/maggiedash-booking/TASKS.md +0 -178
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Bulk author mode: many similar pages from one brief, facts file and validator.
|
|
2
|
+
|
|
3
|
+
One job tracks every target page through write -> validate -> picture -> seed.
|
|
4
|
+
The rules come from a run of 255 industry pages:
|
|
5
|
+
|
|
6
|
+
* a page gets its picture only after it validated and its file has not
|
|
7
|
+
changed since (a picture worker that rewrites a file while a writer still
|
|
8
|
+
edits it loses work);
|
|
9
|
+
* a writer receives the file's current hash and must re-read the page if it
|
|
10
|
+
changed before writing (``record --stage written --expect-hash``);
|
|
11
|
+
* changing the brief, facts or validator mid-run sends every page that passed
|
|
12
|
+
under the old rules back to ``revalidate``;
|
|
13
|
+
* pages are seeded only when every page in the job passed all checks.
|
|
14
|
+
|
|
15
|
+
The validator is the project's own command (JSON argv; the page path is
|
|
16
|
+
appended). Two built-in checks need no command: internal links must point at a
|
|
17
|
+
route of the job or ``--known-routes``, and every price must appear in the
|
|
18
|
+
facts file.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
STAGES = ("planned", "written", "validated", "pictured", "seeded")
|
|
32
|
+
LINK = re.compile(r"""(?:href=["']|\]\(|"(?:href|url|link)"\s*:\s*")(/[^"')\s#?]*)""")
|
|
33
|
+
PRICE = re.compile(r"(?:[£$€¥]\s?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?|(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?\s?(?:GBP|USD|EUR)\b)")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def now() -> str:
|
|
37
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def digest(path: Path) -> str | None:
|
|
41
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()[:16] if path.is_file() else None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def rules_digest(job: dict, project: Path) -> str:
|
|
45
|
+
parts = [json.dumps(job["validator"]), json.dumps(job["checks"]), json.dumps(job["knownRoutes"])]
|
|
46
|
+
for key in ("brief", "facts"):
|
|
47
|
+
parts.append(digest(project / job[key]) or "missing" if job.get(key) else "")
|
|
48
|
+
return hashlib.sha256("\n".join(parts).encode()).hexdigest()[:16]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def job_path(project: Path, job_id: str) -> Path:
|
|
52
|
+
return project / ".maggie" / "design" / f"{job_id}.json"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load(project: Path, job_id: str) -> dict:
|
|
56
|
+
return json.loads(job_path(project, job_id).read_text(encoding="utf-8"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def save(project: Path, job: dict) -> None:
|
|
60
|
+
path = job_path(project, job["id"])
|
|
61
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
path.write_text(json.dumps(job, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def refresh_rules(job: dict, project: Path) -> list[str]:
|
|
66
|
+
"""When the rules changed, earlier passes no longer count."""
|
|
67
|
+
current = rules_digest(job, project)
|
|
68
|
+
if current == job.get("rulesDigest"):
|
|
69
|
+
return []
|
|
70
|
+
moved = []
|
|
71
|
+
for target in job["targets"]:
|
|
72
|
+
if STAGES.index(target["stage"]) >= STAGES.index("validated") and target["stage"] != "seeded":
|
|
73
|
+
target["stage"] = "written"
|
|
74
|
+
target["revalidate"] = True
|
|
75
|
+
moved.append(target["id"])
|
|
76
|
+
job["rulesDigest"] = current
|
|
77
|
+
job["history"].append({"at": now(), "event": "rules-changed", "revalidate": len(moved)})
|
|
78
|
+
return moved
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def builtin_checks(job: dict, project: Path, text: str) -> list[str]:
|
|
82
|
+
errors = []
|
|
83
|
+
if "links" in job["checks"]:
|
|
84
|
+
routes = {target["route"].rstrip("/") or "/" for target in job["targets"]} | {route.rstrip("/") or "/" for route in job["knownRoutes"]}
|
|
85
|
+
for link in sorted(set(LINK.findall(text))):
|
|
86
|
+
if (link.rstrip("/") or "/") not in routes and not link.startswith(("/_", "/api/", "/images/", "/media/", "/fonts/")) and "." not in link.rsplit("/", 1)[-1]:
|
|
87
|
+
errors.append(f"link to {link}, which is not a page of this job or a known route")
|
|
88
|
+
if "prices" in job["checks"] and job.get("facts"):
|
|
89
|
+
facts = (project / job["facts"]).read_text(encoding="utf-8") if (project / job["facts"]).is_file() else ""
|
|
90
|
+
allowed = {re.sub(r"\s", "", value) for value in PRICE.findall(facts)}
|
|
91
|
+
for price in sorted({re.sub(r"\s", "", value) for value in PRICE.findall(text)}):
|
|
92
|
+
if price not in allowed:
|
|
93
|
+
errors.append(f"price {price} is not in the facts file")
|
|
94
|
+
return errors
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def page_text(path: Path) -> str:
|
|
98
|
+
"""The page's text; for a JSON page, its string values (escapes decoded)."""
|
|
99
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
100
|
+
if path.suffix != ".json":
|
|
101
|
+
return raw
|
|
102
|
+
strings: list[str] = []
|
|
103
|
+
|
|
104
|
+
def walk(value: object) -> None:
|
|
105
|
+
if isinstance(value, str):
|
|
106
|
+
strings.append(value)
|
|
107
|
+
elif isinstance(value, dict):
|
|
108
|
+
for key, item in value.items():
|
|
109
|
+
# Keep "href": "/x" shapes visible to the link check.
|
|
110
|
+
if isinstance(item, str) and key in {"href", "url", "link"}:
|
|
111
|
+
strings.append(f'"{key}": "{item}"')
|
|
112
|
+
else:
|
|
113
|
+
walk(item)
|
|
114
|
+
elif isinstance(value, list):
|
|
115
|
+
for item in value:
|
|
116
|
+
walk(item)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
walk(json.loads(raw))
|
|
120
|
+
except json.JSONDecodeError:
|
|
121
|
+
return raw
|
|
122
|
+
return "\n".join(strings)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def validate_target(job: dict, project: Path, target: dict, timeout: int) -> dict:
|
|
126
|
+
path = project / target["file"]
|
|
127
|
+
if not path.is_file():
|
|
128
|
+
return {"passed": False, "errors": ["page file does not exist yet"]}
|
|
129
|
+
errors = builtin_checks(job, project, page_text(path))
|
|
130
|
+
if job["validator"]:
|
|
131
|
+
try:
|
|
132
|
+
completed = subprocess.run([*job["validator"], str(path)], cwd=project, capture_output=True, text=True, timeout=timeout, check=False)
|
|
133
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
134
|
+
return {"passed": False, "errors": errors + [f"validator could not run: {type(error).__name__}"]}
|
|
135
|
+
if completed.returncode:
|
|
136
|
+
detail = (completed.stdout.strip() or completed.stderr.strip()).splitlines()[-5:]
|
|
137
|
+
errors.extend(["validator: " + line[:300] for line in detail] or ["validator failed"])
|
|
138
|
+
return {"passed": not errors, "errors": errors}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def eligible(job: dict, project: Path, stage: str) -> list[dict]:
|
|
142
|
+
batch = []
|
|
143
|
+
for target in job["targets"]:
|
|
144
|
+
current = digest(project / target["file"])
|
|
145
|
+
if stage == "write" and target["stage"] in {"planned", "written"} and (target["stage"] == "planned" or target.get("revalidate") or target.get("lastErrors")):
|
|
146
|
+
batch.append({"id": target["id"], "route": target["route"], "file": target["file"], "currentHash": target.get("knownHash") or "none", "errors": target.get("lastErrors", [])})
|
|
147
|
+
elif stage == "picture" and target["stage"] == "validated" and current == target.get("validatedHash"):
|
|
148
|
+
# Stable since validation: nobody is editing it.
|
|
149
|
+
batch.append({"id": target["id"], "route": target["route"], "file": target["file"], "validatedHash": current})
|
|
150
|
+
if len(batch) >= job["batchSize"]:
|
|
151
|
+
break
|
|
152
|
+
return batch
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def command(args: argparse.Namespace) -> int:
|
|
156
|
+
project = args.project.resolve()
|
|
157
|
+
if args.bulk_command == "plan":
|
|
158
|
+
if not args.confirm:
|
|
159
|
+
print("CONFIRMATION_REQUIRED: rerun with --confirm", file=sys.stderr)
|
|
160
|
+
return 2
|
|
161
|
+
raw = json.loads(args.targets.read_text(encoding="utf-8"))
|
|
162
|
+
items = raw.get("targets", raw) if isinstance(raw, dict) else raw
|
|
163
|
+
targets, seen = [], set()
|
|
164
|
+
for item in items:
|
|
165
|
+
route = str(item.get("route") or "")
|
|
166
|
+
if not route.startswith("/") or route == "/":
|
|
167
|
+
raise ValueError(f"target route must be a non-homepage path: {route!r}")
|
|
168
|
+
target_id = str(item.get("id") or route.strip("/").replace("/", "-"))
|
|
169
|
+
if target_id in seen:
|
|
170
|
+
raise ValueError(f"duplicate target id {target_id}")
|
|
171
|
+
seen.add(target_id)
|
|
172
|
+
targets.append({"id": target_id, "route": route, "file": str(item.get("file") or f"src/content/pages/{target_id}.json"), "stage": "planned"})
|
|
173
|
+
job_id = "bulk-" + hashlib.sha256((str(project) + "\n" + "\n".join(t["route"] for t in targets)).encode()).hexdigest()[:12]
|
|
174
|
+
relative = lambda path: str(path.resolve().relative_to(project)) if path else None # noqa: E731
|
|
175
|
+
job = {"schemaVersion": "maggie-design-bulk.v1", "id": job_id, "mode": "bulk-author", "brief": relative(args.brief), "facts": relative(args.facts),
|
|
176
|
+
"validator": json.loads(args.validator) if args.validator else [], "checks": sorted(set(args.check)), "knownRoutes": args.known_routes,
|
|
177
|
+
"batchSize": args.batch_size, "targets": targets, "history": [{"at": now(), "event": "planned", "targets": len(targets)}]}
|
|
178
|
+
if job["validator"] and not (isinstance(job["validator"], list) and all(isinstance(part, str) for part in job["validator"])):
|
|
179
|
+
raise ValueError("--validator must be a JSON argv array")
|
|
180
|
+
job["rulesDigest"] = rules_digest(job, project)
|
|
181
|
+
save(project, job)
|
|
182
|
+
print(json.dumps({"job": job_id, "targets": len(targets), "batchSize": job["batchSize"], "plan": str(job_path(project, job_id))}, indent=2))
|
|
183
|
+
return 0
|
|
184
|
+
job = load(project, args.job)
|
|
185
|
+
revalidate = refresh_rules(job, project)
|
|
186
|
+
if args.bulk_command == "next":
|
|
187
|
+
batch = eligible(job, project, args.stage)
|
|
188
|
+
save(project, job)
|
|
189
|
+
print(json.dumps({"job": job["id"], "stage": args.stage, "revalidate": revalidate, "batch": batch, "rule": "re-read each file and compare currentHash before writing" if args.stage == "write" else "draw only these pages; skip any whose hash changed"}, indent=2, ensure_ascii=False))
|
|
190
|
+
return 0
|
|
191
|
+
if args.bulk_command == "record":
|
|
192
|
+
target = next((item for item in job["targets"] if item["id"] == args.target), None)
|
|
193
|
+
if not target:
|
|
194
|
+
raise ValueError(f"unknown target {args.target}")
|
|
195
|
+
current = digest(project / target["file"])
|
|
196
|
+
if args.stage == "written":
|
|
197
|
+
# knownHash is the file as the job last saw it (written, validated or
|
|
198
|
+
# pictured). A writer who read another version must re-read first.
|
|
199
|
+
if args.expect_hash is None:
|
|
200
|
+
raise ValueError("--expect-hash is required: pass the currentHash from `next --stage write` ('none' for a new page)")
|
|
201
|
+
if args.expect_hash != (target.get("knownHash") or "none"):
|
|
202
|
+
raise ValueError(f"{target['id']} changed since you read it (now {target.get('knownHash')}); re-read the page, apply your edit again and record with the new hash")
|
|
203
|
+
target.update({"stage": "written", "knownHash": current, "revalidate": False})
|
|
204
|
+
elif args.stage == "pictured":
|
|
205
|
+
if target["stage"] != "validated":
|
|
206
|
+
raise ValueError(f"{target['id']} is not validated; validate it before drawing")
|
|
207
|
+
target.update({"stage": "pictured", "pictureHash": current, "knownHash": current})
|
|
208
|
+
target["updatedAt"] = now()
|
|
209
|
+
save(project, job)
|
|
210
|
+
print(json.dumps({"target": target["id"], "stage": target["stage"], "hash": current}, indent=2))
|
|
211
|
+
return 0
|
|
212
|
+
if args.bulk_command == "validate":
|
|
213
|
+
results = []
|
|
214
|
+
for target in job["targets"]:
|
|
215
|
+
if args.only and target["id"] not in args.only:
|
|
216
|
+
continue
|
|
217
|
+
if target["stage"] == "planned" or (target["stage"] in {"validated", "pictured", "seeded"} and digest(project / target["file"]) == target.get("validatedHash") and not target.get("revalidate")):
|
|
218
|
+
continue
|
|
219
|
+
outcome = validate_target(job, project, target, args.timeout)
|
|
220
|
+
current = digest(project / target["file"])
|
|
221
|
+
if outcome["passed"]:
|
|
222
|
+
# A page that changed after its picture needs the picture again.
|
|
223
|
+
target.update({"stage": "validated" if target.get("pictureHash") != current else "pictured", "validatedHash": current, "knownHash": current, "revalidate": False, "lastErrors": []})
|
|
224
|
+
else:
|
|
225
|
+
target.update({"stage": "written", "lastErrors": outcome["errors"][:20]})
|
|
226
|
+
results.append({"id": target["id"], **outcome})
|
|
227
|
+
save(project, job)
|
|
228
|
+
failed = [item for item in results if not item["passed"]]
|
|
229
|
+
print(json.dumps({"job": job["id"], "validated": len(results) - len(failed), "failed": failed[:50], "revalidate": revalidate}, indent=2, ensure_ascii=False))
|
|
230
|
+
return 0 if not failed else 1
|
|
231
|
+
# status / seed
|
|
232
|
+
counts = {stage: sum(1 for target in job["targets"] if target["stage"] == stage) for stage in STAGES}
|
|
233
|
+
stale = [target["id"] for target in job["targets"] if target["stage"] in {"validated", "pictured"} and digest(project / target["file"]) != target.get("validatedHash")]
|
|
234
|
+
ready = counts["pictured"] + counts["seeded"] == len(job["targets"]) and not stale
|
|
235
|
+
if args.bulk_command == "seed":
|
|
236
|
+
if not ready:
|
|
237
|
+
print(json.dumps({"job": job["id"], "seed": "blocked", "counts": counts, "changedAfterValidation": stale, "reason": "every page must be validated, pictured and unchanged since validation before any page is seeded"}, indent=2))
|
|
238
|
+
return 1
|
|
239
|
+
if not args.confirm:
|
|
240
|
+
print("CONFIRMATION_REQUIRED: rerun with --confirm", file=sys.stderr)
|
|
241
|
+
return 2
|
|
242
|
+
for target in job["targets"]:
|
|
243
|
+
target["stage"] = "seeded"
|
|
244
|
+
job["history"].append({"at": now(), "event": "seeded", "targets": len(job["targets"])})
|
|
245
|
+
save(project, job)
|
|
246
|
+
print(json.dumps({"job": job["id"], "seed": "ready", "targets": [{"route": t["route"], "file": t["file"]} for t in job["targets"]]}, indent=2))
|
|
247
|
+
return 0
|
|
248
|
+
save(project, job)
|
|
249
|
+
print(json.dumps({"job": job["id"], "counts": counts, "changedAfterValidation": stale, "revalidate": revalidate, "seedReady": ready}, indent=2))
|
|
250
|
+
return 0
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def add_parser(argv: list[str]) -> argparse.Namespace:
|
|
254
|
+
parser = argparse.ArgumentParser(prog="maggie design bulk", description=__doc__.splitlines()[0])
|
|
255
|
+
sub = parser.add_subparsers(dest="bulk_command", required=True)
|
|
256
|
+
plan = sub.add_parser("plan", help="create a bulk author job from a target list")
|
|
257
|
+
plan.add_argument("--targets", type=Path, required=True, help='JSON [{"id"?, "route", "file"?}] or {"targets": [...]}')
|
|
258
|
+
plan.add_argument("--brief", type=Path)
|
|
259
|
+
plan.add_argument("--facts", type=Path)
|
|
260
|
+
plan.add_argument("--validator", help="JSON argv; the page file path is appended")
|
|
261
|
+
plan.add_argument("--check", action="append", choices=["links", "prices"], default=[])
|
|
262
|
+
plan.add_argument("--known-routes", nargs="*", default=[])
|
|
263
|
+
plan.add_argument("--batch-size", type=int, default=8)
|
|
264
|
+
plan.add_argument("--confirm", action="store_true")
|
|
265
|
+
for name in ("next", "record", "validate", "status", "seed"):
|
|
266
|
+
command_parser = sub.add_parser(name)
|
|
267
|
+
command_parser.add_argument("job")
|
|
268
|
+
if name == "next":
|
|
269
|
+
command_parser.add_argument("--stage", choices=["write", "picture"], required=True)
|
|
270
|
+
if name == "record":
|
|
271
|
+
command_parser.add_argument("--target", required=True)
|
|
272
|
+
command_parser.add_argument("--stage", choices=["written", "pictured"], required=True)
|
|
273
|
+
command_parser.add_argument("--expect-hash", help="the currentHash you read before writing ('none' for a new page)")
|
|
274
|
+
if name == "validate":
|
|
275
|
+
command_parser.add_argument("--only", nargs="*")
|
|
276
|
+
command_parser.add_argument("--timeout", type=int, default=120)
|
|
277
|
+
if name == "seed":
|
|
278
|
+
command_parser.add_argument("--confirm", action="store_true")
|
|
279
|
+
for action in sub.choices.values():
|
|
280
|
+
action.add_argument("--project", type=Path, default=Path.cwd())
|
|
281
|
+
return parser.parse_args(argv)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def main(argv: list[str]) -> int:
|
|
285
|
+
args = add_parser(argv)
|
|
286
|
+
try:
|
|
287
|
+
return command(args)
|
|
288
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
289
|
+
print(f"BLOCKED: maggie design bulk: {error}", file=sys.stderr)
|
|
290
|
+
return 1
|
|
@@ -7,7 +7,10 @@ pressable in JSX/TSX. React Native Web renders ``Pressable`` and the
|
|
|
7
7
|
remove control becomes ``<button>`` inside ``<button>``: invalid DOM, and taps
|
|
8
8
|
land on the wrong control.
|
|
9
9
|
|
|
10
|
-
``layer-check`` flags unlayered CSS that competes with Tailwind v4 utilities
|
|
10
|
+
``layer-check`` flags unlayered CSS that competes with Tailwind v4 utilities,
|
|
11
|
+
and warns about ``width: max-content`` (or ``w-max``) strips that can push a
|
|
12
|
+
390 px page sideways. ``textinput-check`` flags React Native Web multiline
|
|
13
|
+
inputs without a height and grow-only auto-grow.
|
|
11
14
|
Tailwind v4 emits utilities inside ``@layer utilities``; any unlayered rule
|
|
12
15
|
wins over every layer regardless of specificity, so an unlayered
|
|
13
16
|
``h1 { font-family }`` beats ``font-*`` and ``.ph { display: inline-block }``
|
|
@@ -19,6 +22,13 @@ Expo Router modal route (``presentation: "transparentModal"``, ``"modal"``,
|
|
|
19
22
|
surfaces can measure a 0 top inset, so the header and Close button sit under
|
|
20
23
|
the notch or Dynamic Island; web screenshots cannot show it. Read insets with
|
|
21
24
|
``useSafeAreaInsets()`` from the root ``SafeAreaProvider`` instead.
|
|
25
|
+
|
|
26
|
+
``sheet-check`` flags closing a modal sheet and, in the same tick, showing a
|
|
27
|
+
toast, refreshing or writing query data, navigating, or opening another sheet.
|
|
28
|
+
On iOS the closing sheet's transparent window can stay on top of the app and
|
|
29
|
+
swallow every touch until the app is killed. Close first, then do the rest
|
|
30
|
+
once the sheet has slid away (``afterSheetCloses(fn)``: ``setTimeout`` of the
|
|
31
|
+
slide duration on iOS/Android, immediate on web).
|
|
22
32
|
"""
|
|
23
33
|
|
|
24
34
|
from __future__ import annotations
|
|
@@ -239,12 +249,41 @@ def _rule_findings(source: str, start: int, end: int, layered: bool, display: st
|
|
|
239
249
|
index = close + 1
|
|
240
250
|
|
|
241
251
|
|
|
252
|
+
MAX_CONTENT_RULE = re.compile(r"([^{}]+)\{([^{}]*\b(?:min-)?width\s*:\s*max-content[^{}]*)\}", re.I)
|
|
253
|
+
MAX_CONTENT_CLASS = re.compile(r"class(?:Name)?\s*=\s*[\"'`{]([^\"'`}]*\bw-max\b[^\"'`}]*)", re.I)
|
|
254
|
+
OVERFLOW_GUARD = re.compile(r"\b(?:max-width|overflow(?:-x)?)\s*:", re.I)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def overflow_findings(project: Path, css_sources: dict[Path, str], markup: list[Path]) -> list[dict[str, object]]:
|
|
258
|
+
"""width: max-content on a tab strip or toolbar makes a phone page scroll sideways unless it can shrink or scroll."""
|
|
259
|
+
findings: list[dict[str, object]] = []
|
|
260
|
+
for path, source in css_sources.items():
|
|
261
|
+
for match in MAX_CONTENT_RULE.finditer(source):
|
|
262
|
+
body = match.group(2)
|
|
263
|
+
if OVERFLOW_GUARD.search(body):
|
|
264
|
+
continue
|
|
265
|
+
selector = " ".join(match.group(1).split())[-80:]
|
|
266
|
+
findings.append({"file": _display(path, project), "line": source.count("\n", 0, match.start(2)) + 1, "selector": selector, "kind": "max-content", "severity": "warning",
|
|
267
|
+
"message": "width: max-content without max-width/overflow can push a 390 px page sideways; give the strip overflow-x: auto inside a min-width: 0 parent, or max-width: 100%"})
|
|
268
|
+
for path in markup:
|
|
269
|
+
source = path.read_text(encoding="utf-8", errors="replace")
|
|
270
|
+
for match in MAX_CONTENT_CLASS.finditer(source):
|
|
271
|
+
classes = match.group(1)
|
|
272
|
+
if re.search(r"\b(?:overflow-x-auto|overflow-auto|overflow-x-scroll|max-w-[\w\[\]%-]+)\b", classes):
|
|
273
|
+
continue
|
|
274
|
+
findings.append({"file": _display(path, project), "line": source.count("\n", 0, match.start()) + 1, "selector": classes.strip()[:80], "kind": "max-content", "severity": "warning",
|
|
275
|
+
"message": "w-max without overflow-x-auto or a max-w-* can make the page scroll sideways at 390 px"})
|
|
276
|
+
return findings
|
|
277
|
+
|
|
278
|
+
|
|
242
279
|
def layer_check(project: Path, paths: list[Path], force_v4: bool) -> dict[str, object]:
|
|
243
280
|
files = iter_files(paths or [project], (".css", ".pcss", ".postcss"))
|
|
244
281
|
sources = {path.resolve(): _strip_css_comments(path.read_text(encoding="utf-8", errors="replace")) for path in files}
|
|
282
|
+
markup = iter_files(paths or [project], (".tsx", ".jsx", ".astro", ".vue", ".svelte", ".html"))
|
|
283
|
+
overflow = overflow_findings(project, sources, markup)
|
|
245
284
|
tailwind_v4 = force_v4 or any(TAILWIND_V4_IMPORT.search(source) for source in sources.values())
|
|
246
285
|
if not tailwind_v4:
|
|
247
|
-
return {"schemaVersion": "maggie-design-layer-check.v1", "passed": True, "status": "skipped", "reason": "no Tailwind v4 entry (@import \"tailwindcss\") found", "checkedFiles": len(files), "findings":
|
|
286
|
+
return {"schemaVersion": "maggie-design-layer-check.v1", "passed": True, "status": "skipped", "reason": "no Tailwind v4 entry (@import \"tailwindcss\") found", "checkedFiles": len(files), "findings": overflow, "warnings": len(overflow)}
|
|
248
287
|
layered_files: set[Path] = set()
|
|
249
288
|
for path, source in sources.items():
|
|
250
289
|
for target in LAYERED_IMPORT.findall(source):
|
|
@@ -252,6 +291,7 @@ def layer_check(project: Path, paths: list[Path], force_v4: bool) -> dict[str, o
|
|
|
252
291
|
findings: list[dict[str, object]] = []
|
|
253
292
|
for path, source in sources.items():
|
|
254
293
|
_rule_findings(source, 0, len(source), path in layered_files, _display(path, project), findings)
|
|
294
|
+
findings += overflow
|
|
255
295
|
errors = [item for item in findings if item["severity"] == "error"]
|
|
256
296
|
return {"schemaVersion": "maggie-design-layer-check.v1", "passed": not errors, "status": "checked", "checkedFiles": len(files), "errors": len(errors), "warnings": len(findings) - len(errors), "findings": findings}
|
|
257
297
|
|
|
@@ -311,6 +351,158 @@ def safe_area_check(project: Path, paths: list[Path]) -> dict[str, object]:
|
|
|
311
351
|
"recommendation": "Wrap the app once in SafeAreaProvider; in modals read const insets = useSafeAreaInsets() and pad the header/footer. Verify on a notched iPhone (or simulator) — web screenshots cannot show the inset."}
|
|
312
352
|
|
|
313
353
|
|
|
354
|
+
TEXTINPUT_TAG = re.compile(r"<TextInput\b(?:[^<>]|=>|\{[^{}]*(?:\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}[^{}]*)*\})*?/?>", re.S)
|
|
355
|
+
TEXTINPUT_ALLOW = "maggie-allow-textinput"
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def textinput_check(project: Path, paths: list[Path]) -> dict[str, object]:
|
|
359
|
+
"""React Native Web renders a multiline TextInput as a 2-row textarea; flag inputs without a height and grow-only auto-grow."""
|
|
360
|
+
project = project.resolve()
|
|
361
|
+
files = iter_files(paths or [project], (".tsx", ".jsx"))
|
|
362
|
+
findings: list[dict[str, object]] = []
|
|
363
|
+
for path in files:
|
|
364
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
365
|
+
if "TextInput" not in raw or TEXTINPUT_ALLOW in raw:
|
|
366
|
+
continue
|
|
367
|
+
source = _strip_jsx_comments(raw)
|
|
368
|
+
display = _display(path, project)
|
|
369
|
+
for tag in TEXTINPUT_TAG.finditer(source):
|
|
370
|
+
text = tag.group(0)
|
|
371
|
+
if not re.search(r"\bmultiline\b(?!\s*=\s*\{\s*false\s*\})", text):
|
|
372
|
+
continue
|
|
373
|
+
line = _line(source, tag.start())
|
|
374
|
+
sized = re.search(r"\bnumberOfLines\s*=|\brows\s*=|\b(?:height|minHeight)\s*:", text)
|
|
375
|
+
style_ref = re.search(r"\bstyle\s*=\s*\{\s*(?:\[)?\s*(?:styles|s)\.(\w+)", text)
|
|
376
|
+
if not sized and style_ref and re.search(rf"\b{style_ref.group(1)}\s*:\s*\{{[^{{}}]*\b(?:height|minHeight)\s*:", source):
|
|
377
|
+
sized = True
|
|
378
|
+
if not sized:
|
|
379
|
+
findings.append({"file": display, "line": line, "kind": "multiline-without-height", "severity": "error",
|
|
380
|
+
"message": "multiline TextInput without numberOfLines or an explicit height renders a 2-row textarea on web (placeholder high, buttons misaligned); set one line of height and grow by measuring"})
|
|
381
|
+
if "onContentSizeChange" in text and "scrollHeight" not in source:
|
|
382
|
+
findings.append({"file": display, "line": line, "kind": "grow-only-autogrow", "severity": "warning",
|
|
383
|
+
"message": "onContentSizeChange never reports a smaller size on web, so this composer grows but never shrinks; on web collapse to the minimum, read scrollHeight, then set the height"})
|
|
384
|
+
errors = [item for item in findings if item["severity"] == "error"]
|
|
385
|
+
return {"schemaVersion": "maggie-design-textinput-check.v1", "passed": not errors, "checkedFiles": len(files), "findings": findings}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
SHEET_ALLOW = "maggie-allow-sheet-close"
|
|
389
|
+
SHEET_CLOSE = re.compile(r"\bset(?:\w*(?:Sheet|Modal|Drawer|Picker|Dialog))\s*\(\s*(?:null|undefined|false)\s*\)|\bset\w*(?:Open|Visible|Shown|Show)\s*\(\s*false\s*\)|\b(?:onClose|closeSheet|closeModal|dismiss|onDismiss)\s*\(\s*\)")
|
|
390
|
+
SHEET_DEFER_NAMES = ("afterSheetCloses", "setTimeout", "requestAnimationFrame", "queueMicrotask", "InteractionManager.runAfterInteractions", "runAfterInteractions")
|
|
391
|
+
SHEET_DEFER = re.compile(r"\b(?:" + "|".join(map(re.escape, SHEET_DEFER_NAMES)) + r")\s*\(")
|
|
392
|
+
SHEET_TRIGGERS = (
|
|
393
|
+
("toast", re.compile(r"\b(?:toast|showToast|Toast\.show)\s*\(")),
|
|
394
|
+
("query-refresh", re.compile(r"\b(?:refetch|invalidateQueries|refetchQueries|resetQueries|mutate|revalidate|reload|refresh)\s*\(")),
|
|
395
|
+
("set-query-data", re.compile(r"\bsetQueryData\s*\(")),
|
|
396
|
+
("navigation", re.compile(r"\b(?:router|navigation|navigator)\.(?:push|replace|navigate|back|dismiss\w*)\s*\(|\bLinking\.openURL\s*\(")),
|
|
397
|
+
("another-sheet", re.compile(r"\bset\w*(?:Sheet|Modal|Drawer|Picker|Dialog)\s*\(\s*(?!null|undefined|false)[^)\s]")),
|
|
398
|
+
("another-sheet", re.compile(r"\bset\w*(?:Open|Visible|Shown|Show)\s*\(\s*true\s*\)")),
|
|
399
|
+
("navigation", re.compile(r"\b\w+\.open\s*\(|\bopenSheet\s*\(")),
|
|
400
|
+
)
|
|
401
|
+
# A parent's callback often toasts or refreshes; worth a look, not an error.
|
|
402
|
+
SHEET_CALLBACK = re.compile(r"(?<![.\w])on[A-Z]\w*\s*\(")
|
|
403
|
+
SHEET_PROP = re.compile(r"<(?:\w+\.)?\w*(?:Sheet|Modal|Drawer|Dialog)\b[^>]*?\b(?:open|visible|isOpen|isVisible)\s*=\s*\{([^{}]*)\}", re.S)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
SHEET_PAUSE = re.compile(r"\bawait\s+(?:new\s+Promise\s*\([^;]*?\bsetTimeout\b|(?:sleep|delay|wait|pause)\s*\()")
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _defer_pattern(source: str) -> re.Pattern[str]:
|
|
410
|
+
"""Built-in deferrals plus local helpers whose body defers (e.g. `const later = (fn) => { ... setTimeout ... }`)."""
|
|
411
|
+
names = []
|
|
412
|
+
for match in re.finditer(r"(?:const|let)\s+(\w+)\s*=\s*(?:async\s*)?\([^\n]*?\)\s*(?::[^={\n]+)?=>\s*\{|function\s+(\w+)\s*\([^\n]*?\)[^{\n]*\{", source):
|
|
413
|
+
body = source[match.end():match.end() + 600]
|
|
414
|
+
if re.search(r"\b(?:setTimeout|afterSheetCloses|requestAnimationFrame|runAfterInteractions)\b", body.split("\n}", 1)[0]):
|
|
415
|
+
names.append(match.group(1) or match.group(2))
|
|
416
|
+
if not names:
|
|
417
|
+
return SHEET_DEFER
|
|
418
|
+
return re.compile(r"\b(?:" + "|".join(map(re.escape, (*SHEET_DEFER_NAMES, *names))) + r")\s*\(")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _call_end(source: str, open_paren: int) -> int:
|
|
422
|
+
level, cursor = 0, open_paren
|
|
423
|
+
while cursor < len(source):
|
|
424
|
+
if source[cursor] == "(":
|
|
425
|
+
level += 1
|
|
426
|
+
elif source[cursor] == ")":
|
|
427
|
+
level -= 1
|
|
428
|
+
if level == 0:
|
|
429
|
+
return cursor
|
|
430
|
+
cursor += 1
|
|
431
|
+
return len(source)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _same_tick(source: str, start: int, defer: re.Pattern[str]) -> str:
|
|
435
|
+
"""Text that runs synchronously after `start` in the same block, with deferred callbacks blanked out."""
|
|
436
|
+
out: list[str] = []
|
|
437
|
+
depth = 0
|
|
438
|
+
index = start
|
|
439
|
+
while index < len(source):
|
|
440
|
+
deferred = defer.match(source, index)
|
|
441
|
+
if deferred:
|
|
442
|
+
index = _call_end(source, deferred.end() - 1) + 1
|
|
443
|
+
continue
|
|
444
|
+
if SHEET_PAUSE.match(source, index):
|
|
445
|
+
break # awaiting a timer longer than nothing: the rest runs later
|
|
446
|
+
char = source[index]
|
|
447
|
+
if char in "({[":
|
|
448
|
+
depth += 1
|
|
449
|
+
elif char in ")}]":
|
|
450
|
+
depth -= 1
|
|
451
|
+
if depth < 0:
|
|
452
|
+
break
|
|
453
|
+
out.append(char)
|
|
454
|
+
index += 1
|
|
455
|
+
return "".join(out)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def sheet_check(project: Path, paths: list[Path]) -> dict[str, object]:
|
|
459
|
+
project = project.resolve()
|
|
460
|
+
files = iter_files(paths or [project], (".tsx", ".jsx", ".ts", ".js"))
|
|
461
|
+
findings: list[dict[str, object]] = []
|
|
462
|
+
for path in files:
|
|
463
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
464
|
+
if SHEET_ALLOW in raw or not re.search(r"\bSheet\b|<Modal\b|BottomSheet|presentation", raw):
|
|
465
|
+
continue
|
|
466
|
+
source = _strip_jsx_comments(raw)
|
|
467
|
+
display = _display(path, project)
|
|
468
|
+
# The state behind each sheet's open/visible prop: its setter closes the sheet.
|
|
469
|
+
names = {name for expression in SHEET_PROP.findall(source) for name in re.findall(r"(?<![.\w])([a-z]\w*)\b", re.sub(r"(['\"`])(?:\\.|(?!\1).)*\1", "", expression)) if name not in {"true", "false", "null", "undefined"}}
|
|
470
|
+
setters = [f"set{name[0].upper()}{name[1:]}" for name in names]
|
|
471
|
+
close_pattern = SHEET_CLOSE if not setters else re.compile(SHEET_CLOSE.pattern + "|\\b(?:" + "|".join(map(re.escape, setters)) + ")\\s*\\(\\s*(?:null|undefined|false|\"\"|'')\\s*\\)")
|
|
472
|
+
defer = _defer_pattern(source)
|
|
473
|
+
deferred_spans = [(match.end(), _call_end(source, match.end() - 1)) for match in defer.finditer(source)]
|
|
474
|
+
for close in close_pattern.finditer(source):
|
|
475
|
+
# A close inside a deferred callback already runs after the slide.
|
|
476
|
+
if any(begin <= close.start() <= end for begin, end in deferred_spans):
|
|
477
|
+
continue
|
|
478
|
+
# `const close = () => setOpen(false)`: the function only closes.
|
|
479
|
+
if re.search(r"=>\s*$", source[max(0, close.start() - 40):close.start()]):
|
|
480
|
+
continue
|
|
481
|
+
after = _same_tick(source, close.end(), defer)
|
|
482
|
+
seen: set[str] = set()
|
|
483
|
+
for kind, pattern in SHEET_TRIGGERS:
|
|
484
|
+
match = pattern.search(after)
|
|
485
|
+
if match and kind not in seen:
|
|
486
|
+
seen.add(kind)
|
|
487
|
+
findings.append({"file": display, "line": _line(source, close.start()), "kind": kind, "severity": "error", "close": close.group(0), "then": match.group(0).strip(),
|
|
488
|
+
"message": f"`{close.group(0)}` is followed in the same tick by {kind.replace('-', ' ')} (`{match.group(0).strip()}`); on iOS the closing sheet can stay on screen and take every touch. Close first, then run the rest in afterSheetCloses(() => ...)."})
|
|
489
|
+
callback = SHEET_CALLBACK.search(after)
|
|
490
|
+
if callback and not seen:
|
|
491
|
+
findings.append({"file": display, "line": _line(source, close.start()), "kind": "parent-callback", "severity": "warning", "close": close.group(0), "then": callback.group(0).strip(),
|
|
492
|
+
"message": f"`{close.group(0)}` is followed by `{callback.group(0).strip()}`; if that callback toasts, refreshes, navigates or closes another sheet, defer it with afterSheetCloses."})
|
|
493
|
+
# Content tied to the open state: closing (x = null) also empties the sheet while it slides away.
|
|
494
|
+
for tag in re.finditer(r"<(?:\w+\.)?\w*(?:Sheet|Modal|Drawer|Dialog)\b[^>]*?\b(?:open|visible|isOpen|isVisible)\s*=\s*\{\s*(?:Boolean\(\s*(\w+)\s*\)|!!\s*(\w+)|(\w+)\s*!==?\s*(?:null|undefined))\s*\}", source, re.S):
|
|
495
|
+
name = next(group for group in tag.groups() if group)
|
|
496
|
+
rest = source[tag.end():tag.end() + 1500]
|
|
497
|
+
body = rest[:rest.find("</")] if "</" in rest else rest
|
|
498
|
+
if re.search(rf"(?<![\w.]){re.escape(name)}\s*(?:\?\.|\.|\[)", source[tag.start():tag.end()] + body):
|
|
499
|
+
findings.append({"file": display, "line": _line(source, tag.start()), "kind": "content-tied-to-open", "severity": "warning", "close": f"set{name[0].upper()}{name[1:]}(null)", "then": f"{name}.… in the sheet",
|
|
500
|
+
"message": f"the sheet opens on `{name}` and shows `{name}`'s fields, so closing it (clearing `{name}`) changes its content while it slides away; keep a separate open flag and clear `{name}` in afterSheetCloses (or leave it until the next open)."})
|
|
501
|
+
errors = [item for item in findings if item["severity"] == "error"]
|
|
502
|
+
return {"schemaVersion": "maggie-design-sheet-check.v1", "passed": not errors, "checkedFiles": len(files), "findings": findings,
|
|
503
|
+
"fix": "export function afterSheetCloses(fn: () => void) { if (Platform.OS === \"web\") fn(); else setTimeout(fn, SHEET_CLOSE_MS); } // SHEET_CLOSE_MS ~ the slide-out duration (about 450 ms)"}
|
|
504
|
+
|
|
505
|
+
|
|
314
506
|
def main(argv: list[str] | None = None) -> int:
|
|
315
507
|
parser = argparse.ArgumentParser(prog="maggie design", description="Static design checks that run before browser QA.")
|
|
316
508
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
@@ -325,9 +517,19 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
325
517
|
safe = sub.add_parser("safe-area-check", help="flag SafeAreaView inside Expo Router modal routes and React Native Modal (.tsx/.jsx)")
|
|
326
518
|
safe.add_argument("--project", type=Path, default=Path.cwd())
|
|
327
519
|
safe.add_argument("--path", type=Path, action="append", default=[], help="file or directory to scan; defaults to the project")
|
|
520
|
+
textinput = sub.add_parser("textinput-check", help="flag multiline TextInput without a height and grow-only auto-grow on React Native Web (.tsx/.jsx)")
|
|
521
|
+
textinput.add_argument("--project", type=Path, default=Path.cwd())
|
|
522
|
+
textinput.add_argument("--path", type=Path, action="append", default=[], help="file or directory to scan; defaults to the project")
|
|
523
|
+
sheet = sub.add_parser("sheet-check", help="flag closing a modal sheet and, in the same tick, a toast, refresh, query write, navigation or another sheet (iOS freeze)")
|
|
524
|
+
sheet.add_argument("--project", type=Path, default=Path.cwd())
|
|
525
|
+
sheet.add_argument("--path", type=Path, action="append", default=[], help="file or directory to scan; defaults to the project")
|
|
328
526
|
args = parser.parse_args(argv)
|
|
329
527
|
try:
|
|
330
|
-
if args.command == "
|
|
528
|
+
if args.command == "sheet-check":
|
|
529
|
+
report = sheet_check(args.project, args.path)
|
|
530
|
+
elif args.command == "textinput-check":
|
|
531
|
+
report = textinput_check(args.project, args.path)
|
|
532
|
+
elif args.command == "safe-area-check":
|
|
331
533
|
report = safe_area_check(args.project, args.path)
|
|
332
534
|
elif args.command == "pressable-check":
|
|
333
535
|
report = pressable_check(args.project, args.path, args.pressable)
|