@topy-ai/maggie 0.7.47 → 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 +76 -15
- package/bin/maggie.js +23 -6
- 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-design/palette-roles-v1.schema.json +47 -0
- package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
- 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-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
- package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
- package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
- package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
- package/bundled-marketplace/README.md +17 -0
- package/bundled-marketplace/catalog.json +56 -7
- package/bundled-references/browser-inspection.md +25 -0
- package/bundled-references/google-integrations-runbook.md +54 -0
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
- package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
- package/bundled-references/maggiedash-booking/PRD.md +9 -1
- package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
- package/bundled-references/maggiedash-booking/README.md +23 -6
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
- package/bundled-references/maggiedash-booking/TASKS.md +44 -3
- package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
- package/bundled-references/provider-adapter-contract.md +33 -0
- package/bundled-skills/maggie-booking/SKILL.md +8 -0
- package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -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 +141 -7
- package/bundled-skills/maggie-deployment/references/vps.md +74 -1
- package/bundled-skills/maggie-design/SKILL.md +154 -0
- package/bundled-skills/maggie-feedback/SKILL.md +67 -1
- package/bundled-skills/maggie-marketplace/SKILL.md +91 -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-qa-workflow/SKILL.md +41 -1
- package/bundled-skills/maggie-service-booking/SKILL.md +82 -0
- package/bundled-skills/maggie-social-share/SKILL.md +56 -7
- package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +152 -0
- package/bundled-skills/maggie-template/SKILL.md +57 -3
- package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
- package/bundled-tools/clis/maggie_booking.py +199 -26
- package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
- package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
- package/bundled-tools/clis/maggie_deployment.py +307 -18
- 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 +17 -0
- package/bundled-tools/clis/maggie_design_lint.py +277 -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 +115 -5
- 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_marketplace.py +199 -5
- package/bundled-tools/clis/maggie_notifications.py +528 -0
- package/bundled-tools/clis/maggie_ops.py +87 -0
- package/bundled-tools/clis/maggie_palette.py +280 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
- 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 +442 -0
- package/bundled-tools/clis/maggie_social_share.py +46 -9
- package/bundled-tools/clis/maggie_template_package.py +1141 -0
- 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/browser-inspection.md +25 -0
- package/references/google-integrations-runbook.md +54 -0
- package/references/provider-adapter-contract.md +33 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Audit project documentation for stale trackers and
|
|
2
|
+
"""Audit project documentation for stale trackers, unreferenced files, and conflicting rollout status."""
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
@@ -126,16 +126,94 @@ def audit(project: Path, docs_dir: str = "docs") -> dict:
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
|
|
129
|
+
HISTORICAL_RE = re.compile(r"documentation-status:\s*historical-snapshot", re.I)
|
|
130
|
+
ROLLOUT_STATES = ("planned", "in_progress", "blocked", "complete")
|
|
131
|
+
CLAIM_PATTERNS = (
|
|
132
|
+
("complete", re.compile(r"(?i)^\s*[-*]\s*\[x\]|\b(?:complete|completed|done|shipped|live|resolved)\b")),
|
|
133
|
+
("open", re.compile(r"(?i)^\s*[-*]\s*\[ \]|\b(?:in[ -]progress|pending|ongoing|planned|not started|blocked|todo|unchecked)\b")),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _claim(line: str) -> str | None:
|
|
138
|
+
"""Return complete/open for a status line, or None when absent or ambiguous."""
|
|
139
|
+
found = {state for state, pattern in CLAIM_PATTERNS if pattern.search(line)}
|
|
140
|
+
return found.pop() if len(found) == 1 else None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def rollout_status(project: Path, docs_dir: str = "docs", status_file: str = "docs/rollout-status.json", id_pattern: str = r"\b[A-Z][A-Z0-9]+-\d+\b") -> dict:
|
|
144
|
+
"""Find documents whose rollout claims disagree with each other or with the canonical status file."""
|
|
145
|
+
root = project.resolve()
|
|
146
|
+
docs = (root / docs_dir).resolve()
|
|
147
|
+
errors: list[str] = []
|
|
148
|
+
canonical: dict[str, dict] = {}
|
|
149
|
+
canonical_path = root / status_file
|
|
150
|
+
if canonical_path.is_file():
|
|
151
|
+
data = json.loads(canonical_path.read_text(encoding="utf-8"))
|
|
152
|
+
if data.get("schemaVersion") != "maggie-rollout-status.v1" or not isinstance(data.get("items"), list):
|
|
153
|
+
errors.append(f"{status_file} must use schemaVersion maggie-rollout-status.v1 with an items list")
|
|
154
|
+
for item in data.get("items") or []:
|
|
155
|
+
item_id, state = str(item.get("id") or ""), item.get("status")
|
|
156
|
+
if not item_id or state not in ROLLOUT_STATES:
|
|
157
|
+
errors.append(f"{status_file}: item {item_id or '?'} needs an id and a status in {', '.join(ROLLOUT_STATES)}")
|
|
158
|
+
continue
|
|
159
|
+
if state == "complete" and not item.get("evidence"):
|
|
160
|
+
errors.append(f"{status_file}: {item_id} is complete without evidence")
|
|
161
|
+
canonical[item_id] = item
|
|
162
|
+
pattern = re.compile(id_pattern)
|
|
163
|
+
claims: dict[str, list[dict]] = {}
|
|
164
|
+
historical: list[str] = []
|
|
165
|
+
for path in _files(docs) if docs.is_dir() else []:
|
|
166
|
+
if path.suffix.casefold() not in {".md", ".mdx"}:
|
|
167
|
+
continue
|
|
168
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
169
|
+
relative = str(path.relative_to(root))
|
|
170
|
+
if HISTORICAL_RE.search(text):
|
|
171
|
+
historical.append(relative)
|
|
172
|
+
continue
|
|
173
|
+
for number, line in enumerate(text.splitlines(), 1):
|
|
174
|
+
ids = set(pattern.findall(line))
|
|
175
|
+
claim = _claim(line) if len(ids) == 1 else None
|
|
176
|
+
if claim:
|
|
177
|
+
claims.setdefault(ids.pop(), []).append({"path": relative, "line": number, "claim": claim})
|
|
178
|
+
conflicts = []
|
|
179
|
+
for item_id, found in sorted(claims.items()):
|
|
180
|
+
if item_id in canonical:
|
|
181
|
+
expected = "complete" if canonical[item_id]["status"] == "complete" else "open"
|
|
182
|
+
wrong = [claim for claim in found if claim["claim"] != expected]
|
|
183
|
+
if wrong:
|
|
184
|
+
conflicts.append({"id": item_id, "canonical": canonical[item_id]["status"], "claims": wrong})
|
|
185
|
+
elif len({claim["claim"] for claim in found}) > 1:
|
|
186
|
+
conflicts.append({"id": item_id, "canonical": None, "claims": found})
|
|
187
|
+
errors.extend(f"{item['id']}: conflicting rollout status in {', '.join(sorted({c['path'] for c in item['claims']}))}" for item in conflicts)
|
|
188
|
+
return {
|
|
189
|
+
"schemaVersion": "maggie-rollout-status-audit.v1",
|
|
190
|
+
"passed": not errors,
|
|
191
|
+
"canonical": str(canonical_path.relative_to(root)) if canonical_path.is_file() else None,
|
|
192
|
+
"itemCount": len(claims),
|
|
193
|
+
"historicalSnapshots": historical,
|
|
194
|
+
"conflicts": conflicts,
|
|
195
|
+
"errors": errors,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
129
199
|
def main() -> int:
|
|
130
200
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
131
|
-
parser.add_argument("
|
|
201
|
+
parser.add_argument("command", nargs="?", choices=("audit", "rollout-status"), default="audit")
|
|
132
202
|
parser.add_argument("--project", default=".")
|
|
133
203
|
parser.add_argument("--docs-dir", default="docs")
|
|
134
204
|
parser.add_argument("--fail-on-findings", action="store_true")
|
|
205
|
+
parser.add_argument("--status-file", default="docs/rollout-status.json", help="canonical maggie-rollout-status.v1 file (rollout-status)")
|
|
206
|
+
parser.add_argument("--id-pattern", default=r"\b[A-Z][A-Z0-9]+-\d+\b", help="regex for rollout item IDs such as MSG-14 (rollout-status)")
|
|
135
207
|
parser.add_argument("--output")
|
|
136
208
|
args = parser.parse_args()
|
|
137
|
-
|
|
138
|
-
|
|
209
|
+
if args.command == "rollout-status":
|
|
210
|
+
try:
|
|
211
|
+
result = rollout_status(Path(args.project), args.docs_dir, args.status_file, args.id_pattern)
|
|
212
|
+
except (OSError, ValueError, json.JSONDecodeError, re.error) as error:
|
|
213
|
+
result = {"schemaVersion": "maggie-rollout-status-audit.v1", "passed": False, "errors": [str(error)]}
|
|
214
|
+
else:
|
|
215
|
+
result = audit(Path(args.project), args.docs_dir)
|
|
216
|
+
if args.command == "audit" and args.fail_on_findings and result.get("findingCount"):
|
|
139
217
|
result["passed"] = False
|
|
140
218
|
result.setdefault("errors", []).append("documentation findings require review")
|
|
141
219
|
if args.output:
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check localized email templates and consent-aware lifecycle email plans.
|
|
3
|
+
|
|
4
|
+
Both commands are read-only validators. They never render with real customer
|
|
5
|
+
data, call a provider, or send email. ``email-matrix`` can write preview files
|
|
6
|
+
filled from declared fixtures for human review.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import html
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
EMAIL_SCHEMA = "maggie-email-localization.v1"
|
|
19
|
+
LIFECYCLE_SCHEMA = "maggie-lifecycle-email.v1"
|
|
20
|
+
|
|
21
|
+
LOCALE_SOURCES = ("user", "market", "account", "fallback")
|
|
22
|
+
UNSAFE_LOCALE_SOURCES = ("accept-language", "hard-coded")
|
|
23
|
+
FIELDS = ("subject", "html", "text")
|
|
24
|
+
CATEGORIES = ("transactional", "support", "marketing")
|
|
25
|
+
PLACEHOLDER = re.compile(r"\{\{\s*([A-Za-z_][\w.]*)\s*\}\}|\{([A-Za-z_][\w.]*)\}")
|
|
26
|
+
TAG = re.compile(r"<[^>]+>")
|
|
27
|
+
|
|
28
|
+
LIFECYCLE_STAGES = ("plan", "segment", "consent-check", "preview", "approve", "schedule", "send", "report")
|
|
29
|
+
LIBRARY_KINDS = (
|
|
30
|
+
"opt-in-confirmation", "welcome", "digest", "recommendation", "gift-follow-up",
|
|
31
|
+
"voucher-follow-up", "waitlist-follow-up", "merchant-lead-digest",
|
|
32
|
+
"campaign-announcement", "re-engagement",
|
|
33
|
+
)
|
|
34
|
+
CONSENT_BASES = ("opt-in", "double-opt-in", "soft-opt-in", "legitimate-interest-b2b")
|
|
35
|
+
# The confirmation request is sent before marketing consent exists, so it is
|
|
36
|
+
# transactional and needs no unsubscribe link, but it still has a frequency cap.
|
|
37
|
+
PRE_CONSENT_KINDS = ("opt-in-confirmation",)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def placeholders(value: str) -> set[str]:
|
|
41
|
+
return {a or b for a, b in PLACEHOLDER.findall(value or "")}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def visible_text(value: str) -> str:
|
|
45
|
+
stripped = PLACEHOLDER.sub("", TAG.sub(" ", value or ""))
|
|
46
|
+
return re.sub(r"\s+", " ", html.unescape(stripped)).strip()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def language(locale: str) -> str:
|
|
50
|
+
return (locale or "").split("-")[0].lower()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def route_ok(route: str) -> bool:
|
|
54
|
+
return isinstance(route, str) and (route.startswith("/") or route.startswith("https://"))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def check_locale_resolution(template: dict, errors: list[str]) -> None:
|
|
58
|
+
tid = template.get("id")
|
|
59
|
+
order = template.get("localeResolution")
|
|
60
|
+
if not isinstance(order, list) or not order:
|
|
61
|
+
errors.append(f"{tid}: localeResolution must list the locale sources explicitly")
|
|
62
|
+
return
|
|
63
|
+
unsafe = [item for item in order if item in UNSAFE_LOCALE_SOURCES]
|
|
64
|
+
if unsafe:
|
|
65
|
+
errors.append(f"{tid}: localeResolution must not use {', '.join(unsafe)}; resolve from user, market, account, then fallback")
|
|
66
|
+
known = [item for item in order if item in LOCALE_SOURCES]
|
|
67
|
+
if known != [item for item in LOCALE_SOURCES if item in known]:
|
|
68
|
+
errors.append(f"{tid}: localeResolution precedence must be user > market > account > fallback")
|
|
69
|
+
if order[-1] != "fallback":
|
|
70
|
+
errors.append(f"{tid}: localeResolution must end with fallback")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def check_call_sites(manifest: dict, errors: list[str], warnings: list[str]) -> None:
|
|
74
|
+
templates = {item.get("id"): item for item in manifest.get("templates", []) if isinstance(item, dict)}
|
|
75
|
+
for site in manifest.get("callSites", []) or []:
|
|
76
|
+
source = site.get("localeSource")
|
|
77
|
+
tid = site.get("template")
|
|
78
|
+
label = f"call site {site.get('id') or tid}"
|
|
79
|
+
if tid not in templates:
|
|
80
|
+
errors.append(f"{label}: unknown template {tid}")
|
|
81
|
+
if source == "hard-coded":
|
|
82
|
+
errors.append(f"{label}: sends with a hard-coded locale; pass the resolved recipient locale")
|
|
83
|
+
elif source == "accept-language":
|
|
84
|
+
category = templates.get(tid, {}).get("category")
|
|
85
|
+
if site.get("path") == "fulfilment" or category == "transactional":
|
|
86
|
+
errors.append(f"{label}: fulfilment/transactional email must not use Accept-Language alone; resolve user > market > account > fallback")
|
|
87
|
+
else:
|
|
88
|
+
warnings.append(f"{label}: Accept-Language is only a hint; prefer a stored user or market locale")
|
|
89
|
+
elif source not in ("resolved",) + LOCALE_SOURCES:
|
|
90
|
+
errors.append(f"{label}: localeSource must be resolved, user, market, account or fallback")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def check_template(manifest: dict, template: dict, preview_dir: Path | None, errors: list[str], warnings: list[str]) -> list[dict]:
|
|
94
|
+
tid = template.get("id") or "<unnamed>"
|
|
95
|
+
source_locale = manifest.get("sourceLocale")
|
|
96
|
+
required = template.get("requiredLocales") or manifest.get("locales") or []
|
|
97
|
+
declared = set(template.get("variables") or [])
|
|
98
|
+
markets = manifest.get("markets") or {}
|
|
99
|
+
glossary = manifest.get("glossary") or []
|
|
100
|
+
if template.get("category") not in CATEGORIES:
|
|
101
|
+
errors.append(f"{tid}: category must be one of {', '.join(CATEGORIES)}")
|
|
102
|
+
check_locale_resolution(template, errors)
|
|
103
|
+
locales = template.get("locales") or {}
|
|
104
|
+
source = locales.get(source_locale) or {}
|
|
105
|
+
if not source:
|
|
106
|
+
errors.append(f"{tid}: missing source locale {source_locale}")
|
|
107
|
+
rows = []
|
|
108
|
+
for locale in required:
|
|
109
|
+
entry = locales.get(locale)
|
|
110
|
+
if not isinstance(entry, dict):
|
|
111
|
+
errors.append(f"{tid}: missing locale {locale}")
|
|
112
|
+
continue
|
|
113
|
+
for field in FIELDS:
|
|
114
|
+
if not isinstance(entry.get(field), str) or not entry[field].strip():
|
|
115
|
+
errors.append(f"{tid}/{locale}: missing {field}")
|
|
116
|
+
used = set()
|
|
117
|
+
for field in FIELDS:
|
|
118
|
+
value = entry.get(field) or ""
|
|
119
|
+
found = placeholders(value)
|
|
120
|
+
used |= found
|
|
121
|
+
undeclared = found - declared
|
|
122
|
+
if undeclared:
|
|
123
|
+
errors.append(f"{tid}/{locale}: {field} uses undeclared variables {', '.join(sorted(undeclared))}")
|
|
124
|
+
if locale != source_locale and source.get(field):
|
|
125
|
+
expected = placeholders(source[field])
|
|
126
|
+
if found != expected:
|
|
127
|
+
missing, extra = sorted(expected - found), sorted(found - expected)
|
|
128
|
+
errors.append(f"{tid}/{locale}: {field} variables changed from source (missing {missing or '[]'}, extra {extra or '[]'})")
|
|
129
|
+
if language(locale) != language(source_locale) and visible_text(value) and visible_text(value) == visible_text(source[field]):
|
|
130
|
+
errors.append(f"{tid}/{locale}: {field} is identical to the source text (untranslated)")
|
|
131
|
+
if placeholders(entry.get("html", "")) != placeholders(entry.get("text", "")):
|
|
132
|
+
errors.append(f"{tid}/{locale}: HTML and plain-text bodies use different variables")
|
|
133
|
+
route = entry.get("ctaRoute", template.get("cta", {}).get("route"))
|
|
134
|
+
if template.get("cta") or entry.get("ctaRoute"):
|
|
135
|
+
if not route_ok(route):
|
|
136
|
+
errors.append(f"{tid}/{locale}: CTA route must be a site path or https URL")
|
|
137
|
+
else:
|
|
138
|
+
market = entry.get("market")
|
|
139
|
+
prefix = (markets.get(market) or {}).get("routePrefix") if market else None
|
|
140
|
+
if market and market not in markets:
|
|
141
|
+
errors.append(f"{tid}/{locale}: unknown market {market}")
|
|
142
|
+
elif prefix and route.startswith("/") and not (route == prefix or route.startswith(prefix.rstrip("/") + "/")):
|
|
143
|
+
errors.append(f"{tid}/{locale}: CTA route {route} does not use market prefix {prefix}")
|
|
144
|
+
if market and locale not in ((markets.get(market) or {}).get("locales") or [locale]):
|
|
145
|
+
errors.append(f"{tid}/{locale}: market {market} does not serve locale {locale}")
|
|
146
|
+
base = template.get("cta", {}).get("route") or ""
|
|
147
|
+
if placeholders(route) != placeholders(base) and base:
|
|
148
|
+
errors.append(f"{tid}/{locale}: CTA route parameters differ from the template route")
|
|
149
|
+
for field in ("html", "text"):
|
|
150
|
+
body = entry.get(field) or ""
|
|
151
|
+
if route.split("{")[0] and route.split("{")[0] not in body and "{{ctaUrl}}" not in body and "{ctaUrl}" not in body:
|
|
152
|
+
warnings.append(f"{tid}/{locale}: {field} body does not include the CTA route or {{{{ctaUrl}}}}")
|
|
153
|
+
if locale != source_locale:
|
|
154
|
+
for term in glossary:
|
|
155
|
+
if term.get("locale") != locale:
|
|
156
|
+
continue
|
|
157
|
+
src_term, target = str(term.get("term", "")), str(term.get("translation", ""))
|
|
158
|
+
if src_term and src_term.lower() in visible_text(source.get("text", "")).lower() and target and target not in (entry.get("text") or ""):
|
|
159
|
+
warnings.append(f"{tid}/{locale}: glossary term '{src_term}' is not rendered as '{target}'")
|
|
160
|
+
approval = entry.get("approval") or {}
|
|
161
|
+
approved = approval.get("status") == "approved" and bool(approval.get("reviewer"))
|
|
162
|
+
if not approved:
|
|
163
|
+
warnings.append(f"{tid}/{locale}: not approved; not publishable")
|
|
164
|
+
rows.append({"template": tid, "locale": locale, "market": entry.get("market"), "approved": approved})
|
|
165
|
+
if preview_dir is not None:
|
|
166
|
+
write_preview(preview_dir, tid, locale, entry, template.get("fixtures") or {}, route)
|
|
167
|
+
unused = declared - set().union(*(placeholders((locales.get(l) or {}).get(f, "")) for l in required for f in FIELDS)) if required else set()
|
|
168
|
+
if unused:
|
|
169
|
+
warnings.append(f"{tid}: declared variables never used: {', '.join(sorted(unused))}")
|
|
170
|
+
return rows
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def fill(value: str, fixtures: dict, escape: bool) -> str:
|
|
174
|
+
def replace(match: re.Match) -> str:
|
|
175
|
+
key = match.group(1) or match.group(2)
|
|
176
|
+
raw = str(fixtures.get(key, f"[{key}]"))
|
|
177
|
+
return html.escape(raw) if escape else raw
|
|
178
|
+
return PLACEHOLDER.sub(replace, value or "")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def safe_name(value: str) -> str:
|
|
182
|
+
return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-") or "template"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def write_preview(directory: Path, tid: str, locale: str, entry: dict, fixtures: dict, route: str | None) -> None:
|
|
186
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
stem = f"{safe_name(tid)}.{safe_name(locale)}"
|
|
188
|
+
subject = fill(entry.get("subject", ""), fixtures, escape=False)
|
|
189
|
+
banner = f"<p data-maggie-preview>Preview only — fixture data, not sent. Subject: {html.escape(subject)}. CTA: {html.escape(str(route or ''))}</p>"
|
|
190
|
+
(directory / f"{stem}.html").write_text(banner + fill(entry.get("html", ""), fixtures, escape=True), encoding="utf-8")
|
|
191
|
+
(directory / f"{stem}.txt").write_text(f"Subject: {subject}\n\n" + fill(entry.get("text", ""), fixtures, escape=False), encoding="utf-8")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def email_matrix(manifest: dict, preview_dir: Path | None = None, require_approved: bool = False) -> dict:
|
|
195
|
+
errors: list[str] = []
|
|
196
|
+
warnings: list[str] = []
|
|
197
|
+
if manifest.get("schemaVersion") != EMAIL_SCHEMA:
|
|
198
|
+
errors.append(f"schemaVersion must be {EMAIL_SCHEMA}")
|
|
199
|
+
if not manifest.get("sourceLocale"):
|
|
200
|
+
errors.append("sourceLocale is required")
|
|
201
|
+
templates = manifest.get("templates")
|
|
202
|
+
if not isinstance(templates, list) or not templates:
|
|
203
|
+
errors.append("templates must be a non-empty list")
|
|
204
|
+
templates = []
|
|
205
|
+
seen: set[str] = set()
|
|
206
|
+
rows: list[dict] = []
|
|
207
|
+
for template in templates:
|
|
208
|
+
if not isinstance(template, dict):
|
|
209
|
+
errors.append("template entries must be objects")
|
|
210
|
+
continue
|
|
211
|
+
if template.get("id") in seen:
|
|
212
|
+
errors.append(f"duplicate template id {template.get('id')}")
|
|
213
|
+
seen.add(template.get("id"))
|
|
214
|
+
rows.extend(check_template(manifest, template, preview_dir, errors, warnings))
|
|
215
|
+
check_call_sites(manifest, errors, warnings)
|
|
216
|
+
if require_approved:
|
|
217
|
+
errors.extend(f"{row['template']}/{row['locale']}: approval required" for row in rows if not row["approved"])
|
|
218
|
+
return {
|
|
219
|
+
"schemaVersion": "maggie-email-localization-report.v1",
|
|
220
|
+
"status": "failed" if errors else "passed",
|
|
221
|
+
"templates": len(seen),
|
|
222
|
+
"matrix": rows,
|
|
223
|
+
"publishable": not errors and all(row["approved"] for row in rows),
|
|
224
|
+
"errors": errors,
|
|
225
|
+
"warnings": warnings,
|
|
226
|
+
"previewDir": str(preview_dir) if preview_dir else None,
|
|
227
|
+
"sends": False,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def approved(record: dict | None) -> bool:
|
|
232
|
+
return isinstance(record, dict) and record.get("status") == "approved" and bool(record.get("reviewer")) and bool(record.get("approvedAt"))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def lifecycle_validate(plan: dict) -> dict:
|
|
236
|
+
errors: list[str] = []
|
|
237
|
+
warnings: list[str] = []
|
|
238
|
+
if plan.get("schemaVersion") != LIFECYCLE_SCHEMA:
|
|
239
|
+
errors.append(f"schemaVersion must be {LIFECYCLE_SCHEMA}")
|
|
240
|
+
stages = plan.get("stages")
|
|
241
|
+
if stages != list(LIFECYCLE_STAGES):
|
|
242
|
+
errors.append(f"stages must be exactly {', '.join(LIFECYCLE_STAGES)} in order")
|
|
243
|
+
if (plan.get("defaults") or {}).get("send") != "disabled":
|
|
244
|
+
errors.append("defaults.send must be 'disabled' (no send until approved)")
|
|
245
|
+
templates = plan.get("templates") or []
|
|
246
|
+
ids: dict[str, dict] = {}
|
|
247
|
+
for template in templates:
|
|
248
|
+
tid = template.get("id") or "<unnamed>"
|
|
249
|
+
if tid in ids:
|
|
250
|
+
errors.append(f"duplicate template id {tid}")
|
|
251
|
+
ids[tid] = template
|
|
252
|
+
kind = template.get("kind")
|
|
253
|
+
category = template.get("category")
|
|
254
|
+
if kind not in LIBRARY_KINDS:
|
|
255
|
+
errors.append(f"{tid}: kind must be one of {', '.join(LIBRARY_KINDS)}")
|
|
256
|
+
if category not in CATEGORIES:
|
|
257
|
+
errors.append(f"{tid}: category must be one of {', '.join(CATEGORIES)}")
|
|
258
|
+
if kind in PRE_CONSENT_KINDS:
|
|
259
|
+
if category != "transactional":
|
|
260
|
+
errors.append(f"{tid}: opt-in confirmation is sent before consent and must be transactional, with no promotional content")
|
|
261
|
+
elif category == "marketing":
|
|
262
|
+
consent = template.get("consent") or {}
|
|
263
|
+
if consent.get("basis") not in CONSENT_BASES:
|
|
264
|
+
errors.append(f"{tid}: marketing template needs consent.basis ({', '.join(CONSENT_BASES)})")
|
|
265
|
+
if not consent.get("category"):
|
|
266
|
+
errors.append(f"{tid}: marketing template needs consent.category (preference-centre category)")
|
|
267
|
+
if not route_ok((template.get("unsubscribe") or {}).get("path")):
|
|
268
|
+
errors.append(f"{tid}: marketing template needs an unsubscribe path")
|
|
269
|
+
if not route_ok(template.get("preferenceCentre")):
|
|
270
|
+
errors.append(f"{tid}: marketing template needs a preference-centre path")
|
|
271
|
+
attribution = (template.get("attribution") or {}).get("fields") or []
|
|
272
|
+
if not attribution:
|
|
273
|
+
errors.append(f"{tid}: marketing template needs attribution fields (e.g. source, utm_campaign)")
|
|
274
|
+
elif category == "transactional" and kind not in PRE_CONSENT_KINDS:
|
|
275
|
+
warnings.append(f"{tid}: {kind} is normally marketing; confirm it carries no promotional content")
|
|
276
|
+
frequency = template.get("frequency") or {}
|
|
277
|
+
if not (isinstance(frequency.get("maxPerWindow"), int) and frequency["maxPerWindow"] >= 1 and frequency.get("window")):
|
|
278
|
+
errors.append(f"{tid}: frequency needs maxPerWindow >= 1 and a window (e.g. P7D)")
|
|
279
|
+
if template.get("sendable") and not approved(template.get("approval")):
|
|
280
|
+
errors.append(f"{tid}: sendable without an approved approval record (status, reviewer, approvedAt)")
|
|
281
|
+
for campaign in plan.get("campaigns") or []:
|
|
282
|
+
cid = campaign.get("id") or "<unnamed>"
|
|
283
|
+
for tid in campaign.get("templates") or []:
|
|
284
|
+
if tid not in ids:
|
|
285
|
+
errors.append(f"campaign {cid}: unknown template {tid}")
|
|
286
|
+
elif campaign.get("sendable") and not ids[tid].get("sendable"):
|
|
287
|
+
errors.append(f"campaign {cid}: template {tid} is not approved for sending")
|
|
288
|
+
audience = campaign.get("audience") or {}
|
|
289
|
+
uses_marketing = any(ids.get(tid, {}).get("category") == "marketing" for tid in campaign.get("templates") or [])
|
|
290
|
+
if uses_marketing and not audience.get("requiresConsent"):
|
|
291
|
+
errors.append(f"campaign {cid}: marketing audience must require consent")
|
|
292
|
+
if not audience.get("suppression"):
|
|
293
|
+
errors.append(f"campaign {cid}: audience must honour suppression")
|
|
294
|
+
if not audience.get("dedupe"):
|
|
295
|
+
errors.append(f"campaign {cid}: audience must deduplicate contacts")
|
|
296
|
+
if campaign.get("sendable") and not approved(campaign.get("approval")):
|
|
297
|
+
errors.append(f"campaign {cid}: sendable without an approved approval record")
|
|
298
|
+
if campaign.get("sendable") and not campaign.get("schedule"):
|
|
299
|
+
errors.append(f"campaign {cid}: sendable campaign needs a schedule")
|
|
300
|
+
if not (campaign.get("report") or {}).get("metrics"):
|
|
301
|
+
warnings.append(f"campaign {cid}: no aggregate report metrics declared")
|
|
302
|
+
missing = [kind for kind in LIBRARY_KINDS if kind not in {t.get("kind") for t in templates}]
|
|
303
|
+
return {
|
|
304
|
+
"schemaVersion": "maggie-lifecycle-email-report.v1",
|
|
305
|
+
"status": "failed" if errors else "passed",
|
|
306
|
+
"templates": len(ids),
|
|
307
|
+
"sendable": sorted(tid for tid, item in ids.items() if item.get("sendable")),
|
|
308
|
+
"libraryCoverage": {"missing": missing},
|
|
309
|
+
"errors": errors,
|
|
310
|
+
"warnings": warnings,
|
|
311
|
+
"sends": False,
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def library() -> dict:
|
|
316
|
+
"""A starter plan: every template drafted, nothing sendable."""
|
|
317
|
+
templates = []
|
|
318
|
+
for kind in LIBRARY_KINDS:
|
|
319
|
+
pre = kind in PRE_CONSENT_KINDS
|
|
320
|
+
entry = {
|
|
321
|
+
"id": kind,
|
|
322
|
+
"kind": kind,
|
|
323
|
+
"category": "transactional" if pre else "marketing",
|
|
324
|
+
"frequency": {"maxPerWindow": 1, "window": "P1D" if pre else "P7D"},
|
|
325
|
+
"approval": {"status": "pending"},
|
|
326
|
+
"sendable": False,
|
|
327
|
+
}
|
|
328
|
+
if not pre:
|
|
329
|
+
entry.update({
|
|
330
|
+
"consent": {"basis": "opt-in", "category": "marketing.updates"},
|
|
331
|
+
"unsubscribe": {"path": "/email/unsubscribe/{token}"},
|
|
332
|
+
"preferenceCentre": "/account/notifications",
|
|
333
|
+
"attribution": {"fields": ["source", "utm_source", "utm_medium", "utm_campaign"]},
|
|
334
|
+
})
|
|
335
|
+
templates.append(entry)
|
|
336
|
+
return {"schemaVersion": LIFECYCLE_SCHEMA, "stages": list(LIFECYCLE_STAGES), "defaults": {"send": "disabled"}, "templates": templates, "campaigns": []}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def load(path: Path) -> dict:
|
|
340
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
341
|
+
if not isinstance(data, dict):
|
|
342
|
+
raise ValueError(f"{path} must contain a JSON object")
|
|
343
|
+
return data
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def add_parsers(sub: argparse._SubParsersAction) -> None:
|
|
347
|
+
matrix = sub.add_parser("email-matrix", help="Check localized email templates (locales, variables, routes, parity, approval)")
|
|
348
|
+
matrix.add_argument("manifest", type=Path)
|
|
349
|
+
matrix.add_argument("--preview-dir", type=Path, help="write fixture-filled HTML/text previews for review")
|
|
350
|
+
matrix.add_argument("--require-approved", action="store_true", help="fail when any template locale is not approved")
|
|
351
|
+
lifecycle = sub.add_parser("lifecycle", help="Validate a consent-aware lifecycle/marketing email plan; never sends")
|
|
352
|
+
lifecycle.add_argument("plan", type=Path, nargs="?")
|
|
353
|
+
lifecycle.add_argument("--library", action="store_true", help="print the starter template library (nothing sendable)")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def run(args: argparse.Namespace) -> int:
|
|
357
|
+
if args.command == "email-matrix":
|
|
358
|
+
result = email_matrix(load(args.manifest), args.preview_dir, args.require_approved)
|
|
359
|
+
else:
|
|
360
|
+
if args.library:
|
|
361
|
+
print(json.dumps(library(), indent=2))
|
|
362
|
+
return 0
|
|
363
|
+
if not args.plan:
|
|
364
|
+
raise ValueError("lifecycle needs a plan file or --library")
|
|
365
|
+
result = lifecycle_validate(load(args.plan))
|
|
366
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
367
|
+
return 0 if result["status"] == "passed" else 1
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def main() -> int:
|
|
371
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
372
|
+
add_parsers(parser.add_subparsers(dest="command", required=True))
|
|
373
|
+
args = parser.parse_args()
|
|
374
|
+
try:
|
|
375
|
+
return run(args)
|
|
376
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
377
|
+
print(f"ERROR: {error}", file=sys.stderr)
|
|
378
|
+
return 1
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
if __name__ == "__main__":
|
|
382
|
+
raise SystemExit(main())
|