@topy-ai/maggie 0.6.8 → 0.6.9
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.md +2 -2
- package/README.zh-TW.md +1 -1
- package/bundled-skills/maggie-content-localization/SKILL.md +1 -1
- package/bundled-skills/maggie-deployment/SKILL.md +1 -1
- package/bundled-skills/maggie-design/SKILL.md +1 -1
- package/bundled-skills/maggie-seo-geo/SKILL.md +1 -1
- package/bundled-skills/maggie-service-booking/SKILL.md +1 -1
- package/bundled-tools/clis/maggie_design.py +12 -0
- package/bundled-tools/clis/maggie_localization.py +11 -0
- package/bundled-tools/clis/maggie_service_booking.py +3 -3
- package/bundled-tools/clis/site_audit.py +12 -1
- package/bundled-tools/runtime/maggie_sitemap.py +16 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -179,8 +179,8 @@ artifact schemas.
|
|
|
179
179
|
Recommended upgrade sequence for the current release:
|
|
180
180
|
|
|
181
181
|
```bash
|
|
182
|
-
npx @topy-ai/maggie@0.6.
|
|
183
|
-
npx @topy-ai/maggie@0.6.
|
|
182
|
+
npx @topy-ai/maggie@0.6.9 update --project . --force
|
|
183
|
+
npx @topy-ai/maggie@0.6.9 cleanup --project .
|
|
184
184
|
```
|
|
185
185
|
|
|
186
186
|
## MaggieDash lifecycle
|
package/README.zh-TW.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: maggie-content-localization
|
|
3
3
|
description: Manage translation, polish, rewrite, market localization, review, stale detection, and publishing for pages, guides, posts, services, products, and categories.
|
|
4
4
|
metadata:
|
|
5
|
-
version: 1.
|
|
5
|
+
version: 1.3.0
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Maggie Content Localization
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: maggie-design
|
|
3
3
|
description: Design authorized interior pages, review rendered responsive layouts, or explicitly rebrand a packaged homepage/template. Use rebrand only with a named source brand and target brand.
|
|
4
4
|
metadata:
|
|
5
|
-
version: 1.
|
|
5
|
+
version: 1.4.0
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Maggie Design
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: maggie-seo-geo
|
|
3
3
|
description: Plan, audit, create, rewrite, and measure content for AI CMO's paid SEO and GEO workflow. Use for topic opportunities, AI visibility, technical SEO, extractable article structure, sitemap-based rewrites, GSC readback, or SEO/GEO client reports.
|
|
4
4
|
metadata:
|
|
5
|
-
version: 1.
|
|
5
|
+
version: 1.1.0
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Maggie SEO and GEO
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: maggie-service-booking
|
|
3
3
|
description: Import, synchronise, validate, and design SPA service pages from a booking provider such as Fresha. Use for service catalogues, treatment variants, prices, durations, booking links, payment links, and booking-aware page generation.
|
|
4
4
|
metadata:
|
|
5
|
-
version: 1.
|
|
5
|
+
version: 1.1.0
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Maggie Service Booking
|
|
@@ -526,6 +526,18 @@ def in_place_job(project: Path, routes: list[str], force: bool = False) -> int:
|
|
|
526
526
|
project / "src" / "app" / relative / "page.tsx",
|
|
527
527
|
]
|
|
528
528
|
match = next((path for path in candidates if path.is_file()), None)
|
|
529
|
+
if not match:
|
|
530
|
+
# Dynamic catch-all routes are the source of truth for many page
|
|
531
|
+
# families; a concrete URL still needs to resolve to that route
|
|
532
|
+
# before an in-place design job can be created.
|
|
533
|
+
roots = [project / "src" / "pages", project / "src" / "app"]
|
|
534
|
+
catchalls = {"[...slug]", "[[...slug]]"}
|
|
535
|
+
for root in roots:
|
|
536
|
+
if not root.is_dir():
|
|
537
|
+
continue
|
|
538
|
+
match = next((path for path in root.rglob("*") if path.is_file() and path.stem in catchalls), None)
|
|
539
|
+
if match:
|
|
540
|
+
break
|
|
529
541
|
if not match:
|
|
530
542
|
raise ValueError(f"existing route required for in-place redesign: {route}")
|
|
531
543
|
resolved_routes.append({"route": value, "source": str(match)})
|
|
@@ -186,6 +186,12 @@ def plan_job(args: argparse.Namespace) -> int:
|
|
|
186
186
|
"targetLanguage": args.target_lang,
|
|
187
187
|
"targetLocale": args.locale,
|
|
188
188
|
"operation": args.operation,
|
|
189
|
+
"generationContract": {
|
|
190
|
+
"mode": args.operation,
|
|
191
|
+
"preserveMeaning": args.operation in {"translate", "polish", "localise", "rebrand"},
|
|
192
|
+
"allowStructuralRewrite": args.operation in {"rewrite", "rebrand"},
|
|
193
|
+
"marketAdaptation": args.operation in {"localise", "rebrand"},
|
|
194
|
+
},
|
|
189
195
|
"translationGroupId": identity.get("translationGroupId", f"tg-{content_id}"),
|
|
190
196
|
"sourceRevision": args.source_revision or (source_artifact or {}).get("sourceRevision") or content.get("sourceRevision", "unknown"),
|
|
191
197
|
"status": "draft",
|
|
@@ -218,6 +224,11 @@ def validate_job(path: Path, source_path: Path | None = None, render_path: Path
|
|
|
218
224
|
if not valid_locale(job.get("targetLocale")) or parse_locale(job.get("targetLocale"))[0] != job.get("targetLanguage"):
|
|
219
225
|
errors.append("targetLocale must be a supported tag matching targetLanguage")
|
|
220
226
|
translation = job.get("translation", {})
|
|
227
|
+
generation = job.get("generationContract", {})
|
|
228
|
+
if generation.get("mode") and generation.get("mode") != job.get("operation"):
|
|
229
|
+
errors.append("generationContract mode must match operation")
|
|
230
|
+
if job.get("operation") == "rewrite" and not generation.get("allowStructuralRewrite"):
|
|
231
|
+
errors.append("rewrite requires structural rewrite permission")
|
|
221
232
|
errors.extend(validate_translation({
|
|
222
233
|
"contentId": job.get("contentId"), "lang": job.get("targetLanguage"),
|
|
223
234
|
"locale": job.get("targetLocale"), "translationGroupId": job.get("translationGroupId", f"tg-{job.get('contentId')}"),
|
|
@@ -793,9 +793,9 @@ def cmd_match_pages_review(args):
|
|
|
793
793
|
out=project/".maggie"/"booking"/"page-matches.json"; out.parent.mkdir(parents=True,exist_ok=True); (project/"docs").mkdir(parents=True,exist_ok=True); payload={"matchedAt":NOW(),"requiresManualSelection":True,"candidateServices":report,"selected":0,"conflicts":conflicts}; out.write_text(json.dumps(payload,indent=2,ensure_ascii=False)+"\n",encoding="utf-8"); (project/"docs"/"service-page-matches.json").write_text(json.dumps(payload,indent=2,ensure_ascii=False)+"\n",encoding="utf-8"); print(json.dumps({"status":"conflict-review","pagesScanned":len(candidates),"servicesScanned":len(report),"selected":0,"conflicts":conflicts,"report":str(out)},indent=2)); return 1
|
|
794
794
|
for service,path_value in selected:
|
|
795
795
|
relation={"path":path_value,"role":"canonical","matchMethod":"manual-selection","confidence":1.0}; service["pages"]=[p for p in service.get("pages",[]) if p.get("path")!=path_value]+[relation]
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
796
|
+
# Reviewed selections must not erase existing supporting relations. They
|
|
797
|
+
# may have been imported from a route table or authored intentionally even
|
|
798
|
+
# when the current page scanner cannot see their source file.
|
|
799
799
|
out=project/".maggie"/"booking"/"page-matches.json"; payload={"matchedAt":NOW(),"requiresManualSelection":True,"candidateServices":report,"selected":len(selected),"conflicts":[]}; out.parent.mkdir(parents=True,exist_ok=True); (project/"docs").mkdir(parents=True,exist_ok=True); out.write_text(json.dumps(payload,indent=2,ensure_ascii=False)+"\n",encoding="utf-8"); (project/"docs"/"service-page-matches.json").write_text(json.dumps(payload,indent=2,ensure_ascii=False)+"\n",encoding="utf-8"); save(project,data); print(json.dumps({"status":"review-required","pagesScanned":len(candidates),"servicesScanned":len(report),"selected":len(selected),"conflicts":[],"report":str(out)},indent=2)); return 0
|
|
800
800
|
def cmd_run(args):
|
|
801
801
|
project=root(args); job={"workflow":"maggie-service-booking","phase":"created","source":args.source,"provider":args.provider,"startedAt":NOW(),"history":[]}
|
|
@@ -32,6 +32,7 @@ class PageParser(HTMLParser):
|
|
|
32
32
|
self._jsonld = None
|
|
33
33
|
self.images = []
|
|
34
34
|
self.hreflang = []
|
|
35
|
+
self.robots_directives = []
|
|
35
36
|
|
|
36
37
|
def handle_starttag(self, tag, attrs):
|
|
37
38
|
data = dict(attrs)
|
|
@@ -39,6 +40,8 @@ class PageParser(HTMLParser):
|
|
|
39
40
|
self.lang = data.get("lang", "")
|
|
40
41
|
if tag == "meta" and data.get("name"):
|
|
41
42
|
self.meta[data["name"].lower()] = data.get("content", "")
|
|
43
|
+
if data["name"].lower() == "robots":
|
|
44
|
+
self.robots_directives.append(data.get("content", "").lower())
|
|
42
45
|
if tag == "meta" and data.get("property"):
|
|
43
46
|
self.meta[data["property"].lower()] = data.get("content", "")
|
|
44
47
|
if tag == "title":
|
|
@@ -86,6 +89,8 @@ def audit_page(url: str, html: str, status: int, content_type: str, expected_lan
|
|
|
86
89
|
page = PageParser()
|
|
87
90
|
page.feed(html)
|
|
88
91
|
canonical = urljoin(url, page.canonical) if page.canonical else ""
|
|
92
|
+
robots = page.robots_directives
|
|
93
|
+
robots_tokens = [token.strip() for directive in robots for token in directive.split(",")]
|
|
89
94
|
return {
|
|
90
95
|
"url": url,
|
|
91
96
|
"status": status,
|
|
@@ -105,6 +110,9 @@ def audit_page(url: str, html: str, status: int, content_type: str, expected_lan
|
|
|
105
110
|
"image_count": len(page.images),
|
|
106
111
|
"language": parse_locale(page.lang)[0],
|
|
107
112
|
"hreflang": page.hreflang,
|
|
113
|
+
"robots": not robots or not any(token in {"noindex", "none", "nofollow"} for token in robots_tokens),
|
|
114
|
+
"robots_directives": robots,
|
|
115
|
+
"robots_conflict": len({token for token in robots_tokens if token in {"index", "noindex", "follow", "nofollow", "none"}} & {"index", "noindex"}) > 1 or len({token for token in robots_tokens if token in {"follow", "nofollow", "none"}} & {"follow", "nofollow"}) > 1,
|
|
108
116
|
}
|
|
109
117
|
|
|
110
118
|
|
|
@@ -197,6 +205,9 @@ def main() -> int:
|
|
|
197
205
|
checks["jsonld"] = {"ok": page.jsonld > 0 and all(item is not None for item in page.jsonld_values), "count": page.jsonld}
|
|
198
206
|
checks["entity_jsonld"] = {"ok": any(isinstance(item, dict) and item.get("@type") and (item.get("url") or item.get("@id")) for item in page.jsonld_values), "count": page.jsonld}
|
|
199
207
|
checks["crawlable_links"] = {"ok": page.anchors > 0, "count": page.anchors}
|
|
208
|
+
robots_tokens = [token.strip() for directive in page.robots_directives for token in directive.split(",")]
|
|
209
|
+
checks["robots_directive"] = {"ok": not page.robots_directives or not any(token in {"noindex", "none", "nofollow"} for token in robots_tokens), "directives": page.robots_directives}
|
|
210
|
+
checks["robots_conflict"] = {"ok": not (len({token for token in robots_tokens if token in {"index", "noindex"}}) > 1 or len({token for token in robots_tokens if token in {"follow", "nofollow"}}) > 1), "directives": page.robots_directives}
|
|
200
211
|
if args.check_hreflang or args.check_translation_completeness:
|
|
201
212
|
checks["hreflang"] = hreflang_check(base, page, expected_languages) if args.check_hreflang else {"ok": True, "links": page.hreflang}
|
|
202
213
|
if args.check_translation_completeness and expected_languages:
|
|
@@ -233,7 +244,7 @@ def main() -> int:
|
|
|
233
244
|
page_checks["translation_completeness"] = {"ok": all(lang in {item["lang"] for item in parsed.hreflang} for lang in expected_languages), "expected": sorted(expected_languages)}
|
|
234
245
|
page_checks["passed"] = all(
|
|
235
246
|
page_checks[key]
|
|
236
|
-
for key in ("ok", "title", "h1", "canonical", "description", "locale", "open_graph", "twitter_card", "jsonld", "entity_jsonld", "images")
|
|
247
|
+
for key in ("ok", "title", "h1", "canonical", "description", "locale", "open_graph", "twitter_card", "jsonld", "entity_jsonld", "images", "robots", "robots_conflict")
|
|
237
248
|
) and (not args.check_hreflang or page_checks["hreflang_check"]["ok"]) and (not args.check_translation_completeness or page_checks.get("translation_completeness", {}).get("ok", False))
|
|
238
249
|
except Exception as exc:
|
|
239
250
|
page_checks = {"url": page_url, "passed": False, "error": type(exc).__name__}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import hashlib
|
|
6
|
+
import json
|
|
6
7
|
import re
|
|
7
8
|
from datetime import datetime, timezone
|
|
8
9
|
from html import escape
|
|
@@ -27,10 +28,18 @@ def parse_routes(text: str) -> list[dict[str, str]]:
|
|
|
27
28
|
continue
|
|
28
29
|
parts = line.split("\t")
|
|
29
30
|
if len(parts) < 2:
|
|
30
|
-
raise ValueError("route lines must be type<TAB>absolute-url<TAB>optional-lastmod")
|
|
31
|
+
raise ValueError("route lines must be type<TAB>absolute-url<TAB>optional-lastmod<TAB>optional-alternates-json")
|
|
31
32
|
item = {"type": parts[0].strip(), "url": parts[1].strip()}
|
|
32
33
|
if len(parts) > 2 and parts[2].strip():
|
|
33
34
|
item["lastmod"] = parts[2].strip()
|
|
35
|
+
if len(parts) > 3 and parts[3].strip():
|
|
36
|
+
try:
|
|
37
|
+
alternates = json.loads(parts[3])
|
|
38
|
+
except json.JSONDecodeError as exc:
|
|
39
|
+
raise ValueError("route alternates must be valid JSON") from exc
|
|
40
|
+
if not isinstance(alternates, dict):
|
|
41
|
+
raise ValueError("route alternates must be a JSON object")
|
|
42
|
+
item["alternates"] = alternates
|
|
34
43
|
routes.append(item)
|
|
35
44
|
return routes
|
|
36
45
|
|
|
@@ -63,8 +72,9 @@ def xml_file(urls: list[dict[str, str]]) -> str:
|
|
|
63
72
|
rows = []
|
|
64
73
|
for item in urls:
|
|
65
74
|
lastmod = f"<lastmod>{escape(item['lastmod'])}</lastmod>" if item.get("lastmod") else ""
|
|
66
|
-
|
|
67
|
-
|
|
75
|
+
alternate_xml = "".join(f'<xhtml:link rel="alternate" hreflang="{escape(str(lang))}" href="{escape(str(url))}" />' for lang, url in sorted((item.get("alternates") or {}).items()))
|
|
76
|
+
rows.append(f"<url><loc>{escape(item['url'])}</loc>{lastmod}{alternate_xml}</url>")
|
|
77
|
+
return '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">' + "".join(rows) + "</urlset>\n"
|
|
68
78
|
|
|
69
79
|
|
|
70
80
|
def build_plan(routes: list[dict[str, str]], origin: str, content_types: set[str], chunk_target: int = DEFAULT_CHUNK_TARGET, previous: dict | None = None) -> dict:
|
|
@@ -112,6 +122,9 @@ def validate_plan_data(plan: dict) -> dict:
|
|
|
112
122
|
locs = [element.text or "" for element in root.iter() if element.tag.endswith("loc")]
|
|
113
123
|
if any(not absolute_url(value, origin) for value in locs):
|
|
114
124
|
errors.append(f"chunk contains a relative or off-origin loc: {chunk.get('filename')}")
|
|
125
|
+
for link in root.iter():
|
|
126
|
+
if link.tag.endswith("link") and link.get("rel") == "alternate" and not absolute_url(link.get("href", ""), origin):
|
|
127
|
+
errors.append(f"chunk contains a relative or off-origin alternate: {chunk.get('filename')}")
|
|
115
128
|
except ElementTree.ParseError:
|
|
116
129
|
errors.append(f"chunk is not valid XML: {chunk.get('filename')}")
|
|
117
130
|
index = plan.get("index", {})
|