@topy-ai/maggie 0.7.38 → 0.7.41
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 +82 -6
- package/README.md +99 -7
- package/bin/maggie.js +30 -3
- package/bundled-contracts/maggie-clone/interaction-state-v1.schema.json +26 -0
- package/bundled-contracts/maggie-content/provenance-v1.schema.json +20 -0
- package/bundled-contracts/maggie-design/brand-kit-v1.schema.json +18 -0
- package/bundled-contracts/maggie-design/browser-interactions-v1.schema.json +27 -0
- package/bundled-contracts/maggie-design/css-utility-evidence-v1.schema.json +10 -0
- package/bundled-contracts/maggie-design/dashboard-surface-v1.schema.json +11 -0
- package/bundled-contracts/maggie-design/progressive-enhancement-v1.schema.json +12 -0
- package/bundled-contracts/maggie-design/sample-surface-v1.schema.json +13 -0
- package/bundled-contracts/maggie-design/style-editing-v1.schema.json +35 -0
- package/bundled-contracts/maggie-media/image-generation-policy-v1.json +28 -0
- package/bundled-contracts/maggie-media/video-generation-policy-v1.json +40 -0
- package/bundled-contracts/maggie-media/video-job-v1.schema.json +20 -0
- package/bundled-contracts/maggie-media/video-playback-evidence-v1.schema.json +15 -0
- package/bundled-contracts/maggie-ops/npm11-preflight-v1.schema.json +17 -0
- package/bundled-contracts/maggie-scaffold/host-scaffold-v1.schema.json +25 -0
- package/bundled-contracts/maggie-seo/gsc-readiness-v1.schema.json +19 -0
- package/bundled-contracts/maggie-seo/privacy-origin-evidence-v1.schema.json +11 -0
- package/bundled-contracts/maggie-service-booking/delivery-provider-default-v1.json +8 -0
- package/bundled-contracts/maggie-service-booking/delivery-provider-v1.schema.json +16 -0
- package/bundled-contracts/maggiedash/booking-access-v1.json +5 -4
- package/bundled-contracts/maggiedash/booking-customer-surface-v1.json +12 -1
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +2 -0
- package/bundled-contracts/maggiedash/booking-host-adapter-v1.json +14 -2
- package/bundled-contracts/maggiedash/booking-runtime.v1.json +41 -0
- package/bundled-contracts/maggiedash/browser-session-v1.schema.json +18 -0
- package/bundled-contracts/maggiedash/content-overrides-v1.schema.json +19 -0
- package/bundled-contracts/maggiedash/execution-board.json +526 -26
- package/bundled-contracts/maggiedash/host-capabilities-v1.schema.json +10 -0
- package/bundled-contracts/maggiedash/public-session-cache-v1.schema.json +17 -0
- package/bundled-contracts/maggiedash/site-structure-v1.schema.json +13 -0
- package/bundled-references/browser-inspection.md +21 -0
- package/bundled-references/maggiedash-booking/ARCHITECTURE.md +218 -0
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +92 -0
- package/bundled-references/maggiedash-booking/DATA-FLOW.md +143 -0
- package/bundled-references/maggiedash-booking/DATA-MODEL.md +367 -0
- package/bundled-references/maggiedash-booking/DECISIONS.md +94 -0
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +2387 -0
- package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +314 -0
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +227 -0
- package/bundled-references/maggiedash-booking/PAYMENT-GATEWAY.md +267 -0
- package/bundled-references/maggiedash-booking/PRD.md +228 -0
- package/bundled-references/maggiedash-booking/PROGRESS.md +2434 -0
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +235 -0
- package/bundled-references/maggiedash-booking/README.md +271 -0
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +152 -0
- package/bundled-references/maggiedash-booking/SECURITY-COMPLIANCE.md +158 -0
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +542 -0
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +129 -0
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +107 -0
- package/bundled-references/maggiedash-booking/TASKS.md +137 -0
- package/bundled-references/maggiedash-booking/USER-JOURNEYS.md +224 -0
- package/bundled-references/maggiedash-booking/diagrams/booking-dfd.excalidraw +1 -0
- package/bundled-references/maggiedash-booking/diagrams/booking-dfd.mmd +16 -0
- package/bundled-references/maggiedash-booking/diagrams/booking-dfd.png +0 -0
- package/bundled-references/maggiedash-booking/diagrams/booking-dfd.svg +1 -0
- package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.mmd +19 -0
- package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.png +0 -0
- package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.svg +1 -0
- package/bundled-references/maggiedash-booking/diagrams/manager-journey.excalidraw +1 -0
- package/bundled-references/maggiedash-booking/diagrams/manager-journey.mmd +11 -0
- package/bundled-references/maggiedash-booking/diagrams/manager-journey.png +0 -0
- package/bundled-references/maggiedash-booking/diagrams/manager-journey.svg +1 -0
- package/bundled-references/maggiedash-booking/diagrams/payment-sequence.mmd +20 -0
- package/bundled-references/maggiedash-booking/diagrams/payment-sequence.png +0 -0
- package/bundled-references/maggiedash-booking/diagrams/payment-sequence.svg +1 -0
- package/bundled-references/maggiedash-booking/diagrams/system-context.excalidraw +1 -0
- package/bundled-references/maggiedash-booking/diagrams/system-context.mmd +10 -0
- package/bundled-references/maggiedash-booking/diagrams/system-context.png +0 -0
- package/bundled-references/maggiedash-booking/diagrams/system-context.svg +1 -0
- package/bundled-skills/maggie-blog/SKILL.md +12 -0
- package/bundled-skills/maggie-blog-bootstrap/SKILL.md +39 -0
- package/bundled-skills/maggie-booking/SKILL.md +112 -18
- package/bundled-skills/maggie-clone/SKILL.md +13 -0
- package/bundled-skills/maggie-deployment/SKILL.md +6 -0
- package/bundled-skills/maggie-design/SKILL.md +64 -3
- package/bundled-skills/maggie-ops/SKILL.md +12 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +45 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +14 -0
- package/bundled-tools/clis/maggie_analytics.py +43 -1
- package/bundled-tools/clis/maggie_booking.py +161 -26
- package/bundled-tools/clis/maggie_browser_audit.py +99 -3
- package/bundled-tools/clis/maggie_clone.py +46 -1
- package/bundled-tools/clis/maggie_contracts.py +393 -0
- package/bundled-tools/clis/maggie_dash.py +22 -6
- package/bundled-tools/clis/maggie_design.py +84 -12
- package/bundled-tools/clis/maggie_service_booking.py +50 -1
- package/bundled-tools/clis/maggie_workflows.py +387 -0
- package/bundled-tools/clis/site_audit.py +77 -1
- package/bundled-tools/integrations/analytics.md +14 -0
- package/bundled-tools/runtime/site_baseline.py +3 -0
- package/package.json +1 -1
- package/references/browser-inspection.md +21 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Small, deterministic validators for the cross-skill design/SEO contracts.
|
|
3
|
+
|
|
4
|
+
The commands consume sanitized evidence produced by a browser/build adapter. They
|
|
5
|
+
never fetch a third-party site and never write host source files implicitly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import hashlib
|
|
12
|
+
import html as html_lib
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from urllib.parse import urlparse
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def now() -> str:
|
|
22
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_json(path: Path) -> dict:
|
|
26
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
27
|
+
if not isinstance(value, dict):
|
|
28
|
+
raise ValueError(f"{path} must contain a JSON object")
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def write_json(path: Path | None, value: dict) -> None:
|
|
33
|
+
if path:
|
|
34
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def result(schema: str, errors: list[str], **details: object) -> dict:
|
|
39
|
+
return {"schemaVersion": schema, "passed": not errors, "errors": errors, **details}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def emit_and_exit(value: dict, output: Path | None = None) -> int:
|
|
43
|
+
write_json(output, value)
|
|
44
|
+
print(json.dumps(value, indent=2, ensure_ascii=False))
|
|
45
|
+
return 0 if value.get("passed") else 1
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def require_confirm(args: argparse.Namespace) -> None:
|
|
49
|
+
if not args.confirm:
|
|
50
|
+
raise ValueError("CONFIRMATION_REQUIRED: rerun with --confirm")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def slug(value: str) -> str:
|
|
54
|
+
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") or "contract"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def init_plan(args: argparse.Namespace, mode: str, defaults: dict) -> int:
|
|
58
|
+
require_confirm(args)
|
|
59
|
+
project = Path(args.project).resolve()
|
|
60
|
+
seed = f"{project}:{mode}:{getattr(args, 'route', '')}:{now()}".encode()
|
|
61
|
+
ident = f"{mode}-{hashlib.sha256(seed).hexdigest()[:12]}"
|
|
62
|
+
output = project / ".maggie" / "design" / mode / ident / "plan.json"
|
|
63
|
+
plan = {"schemaVersion": f"maggie-{mode}-surface.v1", "id": ident, "mode": mode, "createdAt": now(), **defaults}
|
|
64
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
output.write_text(json.dumps(plan, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
66
|
+
print(json.dumps({"plan": str(output), "id": ident, "mode": mode}, indent=2))
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def validate_progressive(args: argparse.Namespace) -> int:
|
|
71
|
+
evidence = read_json(Path(args.evidence))
|
|
72
|
+
errors: list[str] = []
|
|
73
|
+
if evidence.get("schemaVersion") != "maggie-progressive-enhancement.v1":
|
|
74
|
+
errors.append("evidence schemaVersion must be maggie-progressive-enhancement.v1")
|
|
75
|
+
region = evidence.get("region") if isinstance(evidence.get("region"), dict) else {}
|
|
76
|
+
if not region.get("id") or not region.get("selector"):
|
|
77
|
+
errors.append("region id and selector are required")
|
|
78
|
+
if region.get("swapScope") != "smallest-changed-region":
|
|
79
|
+
errors.append("swapScope must be smallest-changed-region")
|
|
80
|
+
initial = evidence.get("initial") if isinstance(evidence.get("initial"), dict) else {}
|
|
81
|
+
initial_requests = set(initial.get("thirdPartyRequests") or [])
|
|
82
|
+
initial_ids = initial.get("identities") if isinstance(initial.get("identities"), dict) else {}
|
|
83
|
+
states = evidence.get("states")
|
|
84
|
+
if not isinstance(states, list) or not states:
|
|
85
|
+
errors.append("states must be a non-empty array")
|
|
86
|
+
states = []
|
|
87
|
+
for state in states:
|
|
88
|
+
if not isinstance(state, dict):
|
|
89
|
+
errors.append("each state must be an object")
|
|
90
|
+
continue
|
|
91
|
+
new_requests = sorted(set(state.get("thirdPartyRequests") or []) - initial_requests)
|
|
92
|
+
if new_requests:
|
|
93
|
+
errors.append(f"state {state.get('name', 'unknown')} introduced third-party requests: {', '.join(new_requests)}")
|
|
94
|
+
identities = state.get("identities") if isinstance(state.get("identities"), dict) else {}
|
|
95
|
+
for identity, token in initial_ids.items():
|
|
96
|
+
if identities.get(identity) != token:
|
|
97
|
+
errors.append(f"state {state.get('name', 'unknown')} changed identity {identity}")
|
|
98
|
+
if state.get("outsideRegionStable") is not True:
|
|
99
|
+
errors.append(f"state {state.get('name', 'unknown')} lacks outsideRegionStable=true")
|
|
100
|
+
return emit_and_exit(result("maggie-progressive-enhancement.v1", errors, checkedStates=len(states), region=region), Path(args.output) if args.output else None)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def css_escape(token: str) -> str:
|
|
104
|
+
return "".join((f"\\{char}" if char in r":[]()./%#" else char) for char in token)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def validate_css(args: argparse.Namespace) -> int:
|
|
108
|
+
errors: list[str] = []
|
|
109
|
+
missing: list[str] = []
|
|
110
|
+
classes: list[str] = []
|
|
111
|
+
if args.evidence:
|
|
112
|
+
evidence = read_json(Path(args.evidence))
|
|
113
|
+
if evidence.get("schemaVersion") != "maggie-css-utility-evidence.v1":
|
|
114
|
+
errors.append("evidence schemaVersion must be maggie-css-utility-evidence.v1")
|
|
115
|
+
for element in evidence.get("elements") or []:
|
|
116
|
+
if not isinstance(element, dict):
|
|
117
|
+
errors.append("each CSS evidence element must be an object")
|
|
118
|
+
continue
|
|
119
|
+
for declaration in element.get("declarations") or []:
|
|
120
|
+
winner = declaration.get("winningSource") if isinstance(declaration.get("winningSource"), dict) else {}
|
|
121
|
+
if winner.get("layer") == "unlayered" or winner.get("projectRule") is True:
|
|
122
|
+
errors.append(f"utility declaration {declaration.get('property', 'unknown')} is won by unlayered project CSS")
|
|
123
|
+
else:
|
|
124
|
+
source_html = Path(args.html).read_text(encoding="utf-8")
|
|
125
|
+
source_css = Path(args.css).read_text(encoding="utf-8")
|
|
126
|
+
for raw in re.findall(r'class=["\']([^"\']+)', source_html, re.I):
|
|
127
|
+
classes.extend(item for item in html_lib.unescape(raw).split() if ":" in item or "[" in item)
|
|
128
|
+
classes.extend(args.required_class or [])
|
|
129
|
+
for token in dict.fromkeys(classes):
|
|
130
|
+
if token not in source_css and css_escape(token) not in source_css:
|
|
131
|
+
missing.append(token)
|
|
132
|
+
if missing:
|
|
133
|
+
errors.append("compiled CSS is missing required utility classes: " + ", ".join(missing))
|
|
134
|
+
return emit_and_exit(result("maggie-css-utility-evidence.v1", errors, missingClasses=missing, checkedClasses=list(dict.fromkeys(classes))), Path(args.output) if args.output else None)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def validate_dashboard(args: argparse.Namespace) -> int:
|
|
138
|
+
plan = read_json(Path(args.plan))
|
|
139
|
+
evidence = read_json(Path(args.evidence))
|
|
140
|
+
errors: list[str] = []
|
|
141
|
+
required = {"desktop", "tablet", "mobile", "collapsed-rail", "mobile-drawer", "modal"}
|
|
142
|
+
observed = set(evidence.get("screenshots") or []) | set(evidence.get("states") or [])
|
|
143
|
+
if plan.get("schemaVersion") != "maggie-dashboard-surface.v1":
|
|
144
|
+
errors.append("dashboard plan schemaVersion is invalid")
|
|
145
|
+
if not required <= observed:
|
|
146
|
+
errors.append("dashboard evidence is missing: " + ", ".join(sorted(required - observed)))
|
|
147
|
+
if evidence.get("route") != plan.get("route"):
|
|
148
|
+
errors.append("dashboard evidence route does not match plan")
|
|
149
|
+
return emit_and_exit(result("maggie-dashboard-surface.v1", errors, observed=sorted(observed)), Path(args.output) if args.output else None)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def validate_sample(args: argparse.Namespace) -> int:
|
|
153
|
+
plan = read_json(Path(args.plan))
|
|
154
|
+
evidence = read_json(Path(args.evidence))
|
|
155
|
+
errors: list[str] = []
|
|
156
|
+
if plan.get("schemaVersion") != "maggie-sample-surface.v1":
|
|
157
|
+
errors.append("sample plan schemaVersion is invalid")
|
|
158
|
+
if evidence.get("noindex") is not True:
|
|
159
|
+
errors.append("sample route must be noindex")
|
|
160
|
+
if evidence.get("sitemapIncluded") is not False:
|
|
161
|
+
errors.append("sample route must be excluded from the sitemap")
|
|
162
|
+
if not str(evidence.get("banner") or "").strip():
|
|
163
|
+
errors.append("sample route needs an explicit layout-sample banner")
|
|
164
|
+
if not isinstance(evidence.get("placeholders"), list) or not evidence.get("placeholders"):
|
|
165
|
+
errors.append("sample evidence needs placeholder lines")
|
|
166
|
+
if not isinstance(evidence.get("finishChecklist"), list) or not evidence.get("finishChecklist"):
|
|
167
|
+
errors.append("sample evidence needs a finish checklist")
|
|
168
|
+
return emit_and_exit(result("maggie-sample-surface.v1", errors, route=plan.get("route")), Path(args.output) if args.output else None)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def validate_privacy(args: argparse.Namespace) -> int:
|
|
172
|
+
evidence = read_json(Path(args.evidence))
|
|
173
|
+
errors: list[str] = []
|
|
174
|
+
if evidence.get("schemaVersion") != "maggie-privacy-origin-evidence.v1":
|
|
175
|
+
errors.append("evidence schemaVersion must be maggie-privacy-origin-evidence.v1")
|
|
176
|
+
observed: set[str] = set()
|
|
177
|
+
for page in evidence.get("pages") or []:
|
|
178
|
+
observed.update(str(origin) for origin in (page.get("origins") or []))
|
|
179
|
+
declared = set(str(origin) for origin in evidence.get("policyOrigins") or [])
|
|
180
|
+
if observed - declared:
|
|
181
|
+
errors.append("unlisted third-party origins: " + ", ".join(sorted(observed - declared)))
|
|
182
|
+
if declared - observed and evidence.get("requireObservedPolicyOrigins", True):
|
|
183
|
+
errors.append("privacy policy origins not observed in rendered pages: " + ", ".join(sorted(declared - observed)))
|
|
184
|
+
return emit_and_exit(result("maggie-privacy-origin-evidence.v1", errors, observedOrigins=sorted(observed), policyOrigins=sorted(declared)), Path(args.output) if args.output else None)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def validate_structure(args: argparse.Namespace) -> int:
|
|
188
|
+
model = read_json(Path(args.model))
|
|
189
|
+
evidence = read_json(Path(args.evidence))
|
|
190
|
+
errors: list[str] = []
|
|
191
|
+
if model.get("schemaVersion") != "maggie-site-structure.v1":
|
|
192
|
+
errors.append("model schemaVersion must be maggie-site-structure.v1")
|
|
193
|
+
entries = {str(item.get("route")): item for item in model.get("pages") or [] if isinstance(item, dict) and item.get("route")}
|
|
194
|
+
seen = set()
|
|
195
|
+
for item in evidence.get("pages") or []:
|
|
196
|
+
if not isinstance(item, dict) or not item.get("route"):
|
|
197
|
+
errors.append("each rendered page evidence row needs a route")
|
|
198
|
+
continue
|
|
199
|
+
route = str(item["route"])
|
|
200
|
+
seen.add(route)
|
|
201
|
+
expected = entries.get(route)
|
|
202
|
+
if not expected:
|
|
203
|
+
errors.append(f"rendered route is absent from site structure model: {route}")
|
|
204
|
+
continue
|
|
205
|
+
for field in ("template", "source"):
|
|
206
|
+
if expected.get(field) != item.get(field):
|
|
207
|
+
errors.append(f"{route}: {field} does not match the model")
|
|
208
|
+
if set(expected.get("bands") or []) != set(item.get("bands") or []):
|
|
209
|
+
errors.append(f"{route}: band identities do not match the model")
|
|
210
|
+
for route in sorted(set(entries) - seen):
|
|
211
|
+
errors.append(f"model route has no rendered evidence: {route}")
|
|
212
|
+
return emit_and_exit(result("maggie-site-structure.v1", errors, checkedRoutes=sorted(seen)), Path(args.output) if args.output else None)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def validate_capabilities(args: argparse.Namespace) -> int:
|
|
216
|
+
manifest = read_json(Path(args.manifest))
|
|
217
|
+
errors: list[str] = []
|
|
218
|
+
if manifest.get("schemaVersion") != "maggie-host-capabilities.v1":
|
|
219
|
+
errors.append("manifest schemaVersion must be maggie-host-capabilities.v1")
|
|
220
|
+
endpoints = manifest.get("endpoints")
|
|
221
|
+
if not isinstance(endpoints, list) or not endpoints:
|
|
222
|
+
errors.append("endpoints must be a non-empty array")
|
|
223
|
+
endpoints = []
|
|
224
|
+
seen: set[str] = set()
|
|
225
|
+
for endpoint in endpoints:
|
|
226
|
+
if not isinstance(endpoint, dict):
|
|
227
|
+
errors.append("each capability endpoint must be an object")
|
|
228
|
+
continue
|
|
229
|
+
ident = str(endpoint.get("id") or "")
|
|
230
|
+
if not ident or ident in seen:
|
|
231
|
+
errors.append("capability endpoint ids must be non-empty and unique")
|
|
232
|
+
seen.add(ident)
|
|
233
|
+
if not isinstance(endpoint.get("enabled"), bool):
|
|
234
|
+
errors.append(f"{ident or 'unknown'}: enabled must be boolean")
|
|
235
|
+
if not str(endpoint.get("path") or "").startswith("/"):
|
|
236
|
+
errors.append(f"{ident or 'unknown'}: path must start with /")
|
|
237
|
+
return emit_and_exit(result("maggie-host-capabilities.v1", errors, enabled=sum(1 for item in endpoints if isinstance(item, dict) and item.get("enabled") is True), endpointCount=len(endpoints)), Path(args.output) if args.output else None)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def validate_style_editing(args: argparse.Namespace) -> int:
|
|
241
|
+
"""Validate a closed, host-rendered style editing contract."""
|
|
242
|
+
manifest = read_json(Path(args.manifest))
|
|
243
|
+
errors: list[str] = []
|
|
244
|
+
if manifest.get("schemaVersion") != "maggie-style-editing.v1":
|
|
245
|
+
errors.append("manifest schemaVersion must be maggie-style-editing.v1")
|
|
246
|
+
properties = manifest.get("properties")
|
|
247
|
+
if not isinstance(properties, list) or not properties:
|
|
248
|
+
errors.append("properties must be a non-empty array")
|
|
249
|
+
properties = []
|
|
250
|
+
allowed: set[str] = set()
|
|
251
|
+
patterns: dict[str, str] = {}
|
|
252
|
+
dangerous = re.compile(r"(?:;|url\s*\(|expression\s*\(|<|>|javascript:)", re.I)
|
|
253
|
+
for item in properties:
|
|
254
|
+
if not isinstance(item, dict):
|
|
255
|
+
errors.append("each style property must be an object")
|
|
256
|
+
continue
|
|
257
|
+
name = str(item.get("name") or "")
|
|
258
|
+
pattern = str(item.get("pattern") or "")
|
|
259
|
+
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9-]*", name):
|
|
260
|
+
errors.append(f"invalid style property name: {name or 'unknown'}")
|
|
261
|
+
if name in allowed:
|
|
262
|
+
errors.append(f"duplicate style property: {name}")
|
|
263
|
+
allowed.add(name)
|
|
264
|
+
if not pattern:
|
|
265
|
+
errors.append(f"style property {name or 'unknown'} needs a value pattern")
|
|
266
|
+
else:
|
|
267
|
+
try:
|
|
268
|
+
re.compile(pattern)
|
|
269
|
+
patterns[name] = pattern
|
|
270
|
+
except re.error:
|
|
271
|
+
errors.append(f"style property {name or 'unknown'} has an invalid value pattern")
|
|
272
|
+
for template in manifest.get("templates") or []:
|
|
273
|
+
if not isinstance(template, dict) or not str(template.get("id") or ""):
|
|
274
|
+
errors.append("each style template needs an id")
|
|
275
|
+
continue
|
|
276
|
+
for name, value in (template.get("styles") or {}).items():
|
|
277
|
+
if name not in allowed:
|
|
278
|
+
errors.append(f"style template {template['id']} uses undeclared property: {name}")
|
|
279
|
+
continue
|
|
280
|
+
if not isinstance(value, str) or dangerous.search(value) or not re.fullmatch(patterns[name], value):
|
|
281
|
+
errors.append(f"style template {template['id']} has an unsafe value for {name}")
|
|
282
|
+
return emit_and_exit(result("maggie-style-editing.v1", errors, properties=sorted(allowed), templates=len(manifest.get("templates") or [])), Path(args.output) if args.output else None)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def validate_content_overrides(args: argparse.Namespace) -> int:
|
|
286
|
+
manifest = read_json(Path(args.manifest))
|
|
287
|
+
errors: list[str] = []
|
|
288
|
+
if manifest.get("schemaVersion") != "maggie-content-overrides.v1":
|
|
289
|
+
errors.append("manifest schemaVersion must be maggie-content-overrides.v1")
|
|
290
|
+
expected = {
|
|
291
|
+
"readAtRequestTime": True,
|
|
292
|
+
"sourceLiteralIsFallback": True,
|
|
293
|
+
"writesCreateRevision": True,
|
|
294
|
+
"conflictRequiresSourceRevision": True,
|
|
295
|
+
"orphanPolicy": "report-and-never-publish",
|
|
296
|
+
}
|
|
297
|
+
for key, value in expected.items():
|
|
298
|
+
if manifest.get(key) != value:
|
|
299
|
+
errors.append(f"{key} must be {value!r}")
|
|
300
|
+
if not isinstance(manifest.get("protectedFields"), list) or not manifest.get("protectedFields"):
|
|
301
|
+
errors.append("protectedFields must list factual fields that cannot be overridden")
|
|
302
|
+
return emit_and_exit(result("maggie-content-overrides.v1", errors, protectedFields=manifest.get("protectedFields", [])), Path(args.output) if args.output else None)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def validate_session_contract(args: argparse.Namespace) -> int:
|
|
306
|
+
manifest = read_json(Path(args.manifest))
|
|
307
|
+
errors: list[str] = []
|
|
308
|
+
if manifest.get("schemaVersion") != "maggie-browser-session.v1":
|
|
309
|
+
errors.append("manifest schemaVersion must be maggie-browser-session.v1")
|
|
310
|
+
if manifest.get("mintingBoundary") != "host-adapter":
|
|
311
|
+
errors.append("mintingBoundary must be host-adapter")
|
|
312
|
+
if manifest.get("passwordHandling") != "never-exposed":
|
|
313
|
+
errors.append("passwordHandling must be never-exposed")
|
|
314
|
+
ttl = manifest.get("ttlMinutes")
|
|
315
|
+
if not isinstance(ttl, int) or not 1 <= ttl <= 240:
|
|
316
|
+
errors.append("ttlMinutes must be between 1 and 240")
|
|
317
|
+
for key in ("mintRoute", "revokeRoute"):
|
|
318
|
+
if not str(manifest.get(key) or "").startswith("/"):
|
|
319
|
+
errors.append(f"{key} must be a relative host route")
|
|
320
|
+
return emit_and_exit(result("maggie-browser-session.v1", errors, ttlMinutes=ttl), Path(args.output) if args.output else None)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def validate_cache_boundary(args: argparse.Namespace) -> int:
|
|
324
|
+
evidence = read_json(Path(args.evidence))
|
|
325
|
+
errors: list[str] = []
|
|
326
|
+
if evidence.get("schemaVersion") != "maggie-public-session-cache.v1":
|
|
327
|
+
errors.append("evidence schemaVersion must be maggie-public-session-cache.v1")
|
|
328
|
+
if evidence.get("publicHtmlVariesBySession") is not False:
|
|
329
|
+
errors.append("publicHtmlVariesBySession must be false")
|
|
330
|
+
if evidence.get("sessionCookieHttpOnly") is not True:
|
|
331
|
+
errors.append("sessionCookieHttpOnly must be true")
|
|
332
|
+
if evidence.get("identityHintCookieReadable") is not True:
|
|
333
|
+
errors.append("identityHintCookieReadable must be true")
|
|
334
|
+
if evidence.get("identityRequestWhenHintAbsent") is not False:
|
|
335
|
+
errors.append("identityRequestWhenHintAbsent must be false")
|
|
336
|
+
return emit_and_exit(result("maggie-public-session-cache.v1", errors), Path(args.output) if args.output else None)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def main() -> int:
|
|
340
|
+
parser = argparse.ArgumentParser(prog="maggie contracts")
|
|
341
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
342
|
+
progressive = sub.add_parser("progressive-check")
|
|
343
|
+
progressive.add_argument("--evidence", required=True); progressive.add_argument("--output")
|
|
344
|
+
progressive.set_defaults(func=validate_progressive)
|
|
345
|
+
css = sub.add_parser("css-check")
|
|
346
|
+
css.add_argument("--evidence"); css.add_argument("--html"); css.add_argument("--css"); css.add_argument("--required-class", action="append", default=[]); css.add_argument("--output")
|
|
347
|
+
css.set_defaults(func=validate_css)
|
|
348
|
+
cascade = sub.add_parser("css-cascade-check")
|
|
349
|
+
cascade.add_argument("--evidence", required=True); cascade.add_argument("--output")
|
|
350
|
+
cascade.set_defaults(func=validate_css)
|
|
351
|
+
dash_init = sub.add_parser("dash-init")
|
|
352
|
+
dash_init.add_argument("--project", default="."); dash_init.add_argument("--route", required=True); dash_init.add_argument("--confirm", action="store_true")
|
|
353
|
+
dash_init.set_defaults(func=lambda args: init_plan(args, "dashboard", {"route": args.route, "operatorJourneys": ["first-person task completion"], "shell": {"rail": True, "workArea": True, "tabs": True, "modal": True}, "requiredEvidence": ["desktop", "tablet", "mobile", "collapsed-rail", "mobile-drawer", "modal"], "publicWrite": False}))
|
|
354
|
+
dash_validate = sub.add_parser("dash-validate")
|
|
355
|
+
dash_validate.add_argument("--plan", required=True); dash_validate.add_argument("--evidence", required=True); dash_validate.add_argument("--output")
|
|
356
|
+
dash_validate.set_defaults(func=validate_dashboard)
|
|
357
|
+
sample_init = sub.add_parser("sample-init")
|
|
358
|
+
sample_init.add_argument("--project", default="."); sample_init.add_argument("--route", required=True); sample_init.add_argument("--purpose", required=True); sample_init.add_argument("--confirm", action="store_true")
|
|
359
|
+
sample_init.set_defaults(func=lambda args: init_plan(args, "sample", {"route": args.route, "purpose": args.purpose, "noindex": True, "sitemapIncluded": False, "bannerRequired": True, "placeholderPolicy": "unwritten lines must remain explicit placeholders", "finishChecklist": ["replace placeholder copy", "replace media", "run accessibility and SEO checks"], "publicWrite": False}))
|
|
360
|
+
sample_validate = sub.add_parser("sample-validate")
|
|
361
|
+
sample_validate.add_argument("--plan", required=True); sample_validate.add_argument("--evidence", required=True); sample_validate.add_argument("--output")
|
|
362
|
+
sample_validate.set_defaults(func=validate_sample)
|
|
363
|
+
privacy = sub.add_parser("privacy-check")
|
|
364
|
+
privacy.add_argument("--evidence", required=True); privacy.add_argument("--output")
|
|
365
|
+
privacy.set_defaults(func=validate_privacy)
|
|
366
|
+
structure = sub.add_parser("structure-validate")
|
|
367
|
+
structure.add_argument("--model", required=True); structure.add_argument("--evidence", required=True); structure.add_argument("--output")
|
|
368
|
+
structure.set_defaults(func=validate_structure)
|
|
369
|
+
capabilities = sub.add_parser("capabilities-validate")
|
|
370
|
+
capabilities.add_argument("--manifest", required=True); capabilities.add_argument("--output")
|
|
371
|
+
capabilities.set_defaults(func=validate_capabilities)
|
|
372
|
+
style = sub.add_parser("style-check")
|
|
373
|
+
style.add_argument("--manifest", required=True); style.add_argument("--output")
|
|
374
|
+
style.set_defaults(func=validate_style_editing)
|
|
375
|
+
overrides = sub.add_parser("content-overrides-check")
|
|
376
|
+
overrides.add_argument("--manifest", required=True); overrides.add_argument("--output")
|
|
377
|
+
overrides.set_defaults(func=validate_content_overrides)
|
|
378
|
+
session = sub.add_parser("session-check")
|
|
379
|
+
session.add_argument("--manifest", required=True); session.add_argument("--output")
|
|
380
|
+
session.set_defaults(func=validate_session_contract)
|
|
381
|
+
cache = sub.add_parser("cache-boundary-check")
|
|
382
|
+
cache.add_argument("--evidence", required=True); cache.add_argument("--output")
|
|
383
|
+
cache.set_defaults(func=validate_cache_boundary)
|
|
384
|
+
args = parser.parse_args()
|
|
385
|
+
try:
|
|
386
|
+
return args.func(args)
|
|
387
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
388
|
+
print(f"BLOCKED: maggie contract validation: {error}", file=sys.stderr)
|
|
389
|
+
return 1
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
if __name__ == "__main__":
|
|
393
|
+
raise SystemExit(main())
|
|
@@ -192,9 +192,10 @@ def merge_astro_middleware(path: Path) -> dict[str, object]:
|
|
|
192
192
|
"""Add the stable Maggie paths to an existing conventional Astro middleware.
|
|
193
193
|
|
|
194
194
|
The installer must preserve host-owned auth/redirect logic. This narrow
|
|
195
|
-
merge
|
|
196
|
-
|
|
197
|
-
reported to the caller instead of being rewritten
|
|
195
|
+
merge handles the common `onRequest = defineMiddleware((context, next) =>
|
|
196
|
+
{ ... })` shape and the equivalent destructured context form. Unknown
|
|
197
|
+
middleware shapes are reported to the caller instead of being rewritten
|
|
198
|
+
heuristically.
|
|
198
199
|
"""
|
|
199
200
|
try:
|
|
200
201
|
content = path.read_text(encoding="utf-8")
|
|
@@ -209,8 +210,6 @@ def merge_astro_middleware(path: Path) -> dict[str, object]:
|
|
|
209
210
|
r"export\s+const\s+onRequest\s*=\s*defineMiddleware\(\s*\(\s*context\s*,\s*next\s*\)\s*=>\s*\{",
|
|
210
211
|
content,
|
|
211
212
|
)
|
|
212
|
-
if not match:
|
|
213
|
-
return {"status": "manual", "reason": "middleware is not the supported defineMiddleware((context, next) => {}) shape"}
|
|
214
213
|
block = r'''
|
|
215
214
|
// maggie-auto-rewrite-v1: generated by `maggie booking install`; keep host auth below.
|
|
216
215
|
const maggieRewrites: Array<[RegExp, (path: string) => string]> = [
|
|
@@ -228,7 +227,24 @@ def merge_astro_middleware(path: Path) -> dict[str, object]:
|
|
|
228
227
|
}
|
|
229
228
|
}
|
|
230
229
|
'''
|
|
231
|
-
|
|
230
|
+
if match:
|
|
231
|
+
merged = content[:match.end()] + block + content[match.end():]
|
|
232
|
+
else:
|
|
233
|
+
# Astro examples commonly destructure the request context in the
|
|
234
|
+
# parameter list: `(({ url, request }, next) => { ... })`. Normalize
|
|
235
|
+
# only this explicit shape so the generated rewrite block can use the
|
|
236
|
+
# full context while preserving the host's existing variable names.
|
|
237
|
+
destructured = re.search(
|
|
238
|
+
r"(?P<prefix>export\s+const\s+onRequest\s*=\s*defineMiddleware\(\s*\()\s*\{(?P<properties>[^{}]+)\}\s*,\s*(?P<next>[A-Za-z_$][\w$]*)\s*\)\s*=>\s*\{",
|
|
239
|
+
content,
|
|
240
|
+
)
|
|
241
|
+
if not destructured:
|
|
242
|
+
return {"status": "manual", "reason": "middleware is not a supported defineMiddleware((context, next) => {}) shape"}
|
|
243
|
+
properties = destructured.group("properties").strip()
|
|
244
|
+
next_name = destructured.group("next")
|
|
245
|
+
replacement = f"{destructured.group('prefix')}context, {next_name}) => {{\n const {{ {properties} }} = context;"
|
|
246
|
+
normalized = content[:destructured.start()] + replacement + content[destructured.end():]
|
|
247
|
+
merged = normalized[:destructured.start() + len(replacement)] + block + normalized[destructured.start() + len(replacement):]
|
|
232
248
|
try:
|
|
233
249
|
path.write_text(merged, encoding="utf-8")
|
|
234
250
|
except OSError as error:
|
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import argparse
|
|
12
12
|
import hashlib
|
|
13
|
+
import html as html_lib
|
|
13
14
|
import json
|
|
14
15
|
import re
|
|
15
16
|
import shutil
|
|
@@ -82,6 +83,50 @@ def rendered_asset_preflight(template: Path) -> list[str]:
|
|
|
82
83
|
return errors
|
|
83
84
|
|
|
84
85
|
|
|
86
|
+
def source_anchor_start(source: str, tag: str, reported_offset: int) -> int:
|
|
87
|
+
"""Resolve an Astro compiler offset to the opening bracket of an element."""
|
|
88
|
+
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9:-]*", tag):
|
|
89
|
+
raise ValueError("tag name is invalid")
|
|
90
|
+
bound = max(0, min(len(source), int(reported_offset) + 1))
|
|
91
|
+
start = source.rfind("<" + tag, 0, bound)
|
|
92
|
+
if start < 0:
|
|
93
|
+
raise ValueError(f"opening tag not found near compiler offset: {tag}")
|
|
94
|
+
return start
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def decode_content_text(value: str) -> str:
|
|
98
|
+
"""Decode markup entities once and refuse unknown/double-escaped entities."""
|
|
99
|
+
decoded = html_lib.unescape(value)
|
|
100
|
+
if re.search(r"&(?:[A-Za-z][A-Za-z0-9]+|#\d+|#x[0-9A-Fa-f]+);", decoded):
|
|
101
|
+
raise ValueError("text contains an unknown or double-escaped HTML entity")
|
|
102
|
+
return decoded
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def markup_check(html_path: Path, require_keys: bool) -> int:
|
|
106
|
+
source = html_path.read_text(encoding="utf-8")
|
|
107
|
+
errors: list[str] = []
|
|
108
|
+
if re.search(r"\bstyle\s*=\s*([\"'])\s*\1", source, re.I):
|
|
109
|
+
errors.append("empty style attributes must be omitted")
|
|
110
|
+
keys = re.findall(r"\bdata-maggie-content-key\s*=\s*[\"']([^\"']+)[\"']", source, re.I)
|
|
111
|
+
duplicates = sorted({key for key in keys if keys.count(key) > 1})
|
|
112
|
+
if duplicates:
|
|
113
|
+
errors.append("duplicate content keys: " + ", ".join(duplicates))
|
|
114
|
+
if require_keys:
|
|
115
|
+
for match in re.finditer(r"<(p|h[1-6]|li|button|a|span)\b([^>]*)>([^<>]+)</\1>", source, re.I | re.S):
|
|
116
|
+
attrs, text = match.group(2), " ".join(match.group(3).split())
|
|
117
|
+
if text and not re.search(r"\bdata-maggie-content-key\s*=", attrs, re.I):
|
|
118
|
+
errors.append(f"editable text node lacks data-maggie-content-key: {match.group(1)}")
|
|
119
|
+
try:
|
|
120
|
+
decode_content_text(match.group(3))
|
|
121
|
+
except ValueError as error:
|
|
122
|
+
errors.append(str(error))
|
|
123
|
+
result = {"schemaVersion": "maggie-design-markup.v1", "passed": not errors,
|
|
124
|
+
"errors": errors, "contentKeyCount": len(keys), "requireKeys": require_keys,
|
|
125
|
+
"source": str(html_path.resolve())}
|
|
126
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
127
|
+
return 0 if not errors else 1
|
|
128
|
+
|
|
129
|
+
|
|
85
130
|
def _surface_list(value: str) -> list[str]:
|
|
86
131
|
surfaces = [item.strip().lower() for item in value.split(",") if item.strip()]
|
|
87
132
|
invalid = sorted(set(surfaces) - set(REFERENCE_SURFACES))
|
|
@@ -532,9 +577,31 @@ def design_job_path(project: Path, job_id: str) -> Path:
|
|
|
532
577
|
return project / ".maggie" / "design-jobs" / f"{job_id}.json"
|
|
533
578
|
|
|
534
579
|
|
|
535
|
-
def
|
|
580
|
+
def design_job_candidates(project: Path, job_id: str) -> list[Path]:
|
|
581
|
+
"""Return every supported location for a resumable design job.
|
|
582
|
+
|
|
583
|
+
In-place jobs historically live under ``design-jobs`` while first-party
|
|
584
|
+
author jobs are briefs under ``.maggie/design``. Both are Maggie design
|
|
585
|
+
jobs and the progress commands must treat them identically.
|
|
586
|
+
"""
|
|
587
|
+
root = project.resolve() / ".maggie"
|
|
588
|
+
return [
|
|
589
|
+
root / "design-jobs" / f"{job_id}.json",
|
|
590
|
+
root / "design" / f"{job_id}.json",
|
|
591
|
+
]
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def find_design_job_path(project: Path, job_id: str) -> Path:
|
|
595
|
+
for candidate in design_job_candidates(project, job_id):
|
|
596
|
+
if candidate.is_file():
|
|
597
|
+
return candidate
|
|
598
|
+
expected = design_job_path(project.resolve(), job_id)
|
|
599
|
+
raise ValueError(f"design job not found: {expected}")
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def save_design_job(project: Path, job: dict[str, object], path: Path | None = None) -> None:
|
|
536
603
|
job["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
537
|
-
path = design_job_path(project, str(job["id"]))
|
|
604
|
+
path = path or design_job_path(project, str(job["id"]))
|
|
538
605
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
539
606
|
path.write_text(json.dumps(job, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
540
607
|
|
|
@@ -599,17 +666,13 @@ def run_design_job(urls: list[str], project: Path, clone_run: str, force: bool =
|
|
|
599
666
|
|
|
600
667
|
|
|
601
668
|
def design_status(project: Path, job_id: str) -> int:
|
|
602
|
-
path =
|
|
603
|
-
if not path.exists():
|
|
604
|
-
raise ValueError(f"design job not found: {path}")
|
|
669
|
+
path = find_design_job_path(project, job_id)
|
|
605
670
|
print(path.read_text(encoding="utf-8"), end="")
|
|
606
671
|
return 0
|
|
607
672
|
|
|
608
673
|
|
|
609
674
|
def design_resume(project: Path, job_id: str) -> int:
|
|
610
|
-
path =
|
|
611
|
-
if not path.exists():
|
|
612
|
-
raise ValueError(f"design job not found: {path}")
|
|
675
|
+
path = find_design_job_path(project, job_id)
|
|
613
676
|
job = json.loads(path.read_text(encoding="utf-8"))
|
|
614
677
|
return run_design_job(job["urls"], project, job["clone_run"], force=True)[0]
|
|
615
678
|
|
|
@@ -620,9 +683,7 @@ def _step_records(names: list[str]) -> list[dict[str, object]]:
|
|
|
620
683
|
|
|
621
684
|
def design_step(project: Path, job_id: str, step: str, evidence: str) -> int:
|
|
622
685
|
"""Record one piece of implementation evidence and make progress durable."""
|
|
623
|
-
path =
|
|
624
|
-
if not path.exists():
|
|
625
|
-
raise ValueError(f"design job not found: {path}")
|
|
686
|
+
path = find_design_job_path(project, job_id)
|
|
626
687
|
job = json.loads(path.read_text(encoding="utf-8"))
|
|
627
688
|
steps = job.get("steps", [])
|
|
628
689
|
target = next((item for item in steps if item.get("name") == step), None)
|
|
@@ -774,8 +835,9 @@ def author_job(project: Path, route: str, purpose: str, audience: str, brief_fil
|
|
|
774
835
|
brief = {"schemaVersion": "maggie-page-brief.v1", "route": route, "purpose": purpose.strip() or source_brief["purpose"], "audience": audience.strip() or source_brief["audience"], "contentBrief": source_brief.get("contentBrief", ""), "shell": "homepage-canonical", "status": "draft", "sourceEvidence": None, "approval": {"status": "pending", "actor": None}, "createdAt": datetime.now(timezone.utc).isoformat()}
|
|
775
836
|
digest = hashlib.sha256((str(project) + "\n" + route).encode()).hexdigest()[:12]
|
|
776
837
|
output = project / ".maggie" / "design" / "briefs" / f"{relative.replace('/', '-')}.json"; output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(brief, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
838
|
+
required_steps = ["inspect-shell", "implement-page", "capture-responsive-screenshots", "accessibility-check", "build-check", "approval"]
|
|
777
839
|
plan_path = project / ".maggie" / "design" / f"author-{digest}.json"
|
|
778
|
-
plan_path.write_text(json.dumps({"id": f"author-{digest}", "workflow": "maggie-design", "mode": "author", "phase": "ready", "brief": str(output), "designContract": contract, "sourceUrlRequired": False, "shellSourceOfTruth": "homepage-canonical", "approvalRequired": True, "requiredSteps":
|
|
840
|
+
plan_path.write_text(json.dumps({"id": f"author-{digest}", "workflow": "maggie-design", "mode": "author", "phase": "ready", "brief": str(output), "designContract": contract, "sourceUrlRequired": False, "shellSourceOfTruth": "homepage-canonical", "approvalRequired": True, "requiredSteps": required_steps, "required_steps": required_steps, "steps": _step_records(required_steps), "history": [{"phase": "ready", "at": datetime.now(timezone.utc).isoformat()}]}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
779
841
|
print(json.dumps({"brief": str(output), "plan": str(plan_path), "phase": "ready"}, indent=2)); return 0
|
|
780
842
|
|
|
781
843
|
|
|
@@ -791,6 +853,7 @@ commands:
|
|
|
791
853
|
status show a design job and its per-step progress
|
|
792
854
|
resume restart a failed design workflow
|
|
793
855
|
author plan an original first-party page
|
|
856
|
+
markup-check validate optional style attributes, content keys, and entities
|
|
794
857
|
reference-ui, init, validate-ui, app-init, app-validate, rebrand, review
|
|
795
858
|
""")
|
|
796
859
|
return 0
|
|
@@ -806,6 +869,15 @@ commands:
|
|
|
806
869
|
return reference_ui(args.project, args.reference, _surface_list(args.surface), args.screenshots_dir, args.confirm)
|
|
807
870
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
808
871
|
print(f"BLOCKED: maggie-design reference-ui: {error}", file=sys.stderr); return 1
|
|
872
|
+
if len(sys.argv) > 1 and sys.argv[1] == "markup-check":
|
|
873
|
+
command = argparse.ArgumentParser(description="Validate generated markup before it is made editable.")
|
|
874
|
+
command.add_argument("--html", type=Path, required=True)
|
|
875
|
+
command.add_argument("--require-content-keys", action="store_true")
|
|
876
|
+
args = command.parse_args(sys.argv[2:])
|
|
877
|
+
try:
|
|
878
|
+
return markup_check(args.html, args.require_content_keys)
|
|
879
|
+
except (OSError, ValueError) as error:
|
|
880
|
+
print(f"BLOCKED: maggie-design markup-check: {error}", file=sys.stderr); return 1
|
|
809
881
|
if len(sys.argv) > 1 and sys.argv[1] == "init":
|
|
810
882
|
command = argparse.ArgumentParser(description="Initialize a native blog or service UI plan from a sanitized reference manifest.")
|
|
811
883
|
command.add_argument("--project", type=Path, default=Path.cwd())
|