@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 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.8 update --project . --force
183
- npx @topy-ai/maggie@0.6.8 cleanup --project .
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
@@ -8,7 +8,7 @@ Codex、Claude Code 與相容的 coding agents。
8
8
  ## 安裝
9
9
 
10
10
  ```bash
11
- npx @topy-ai/maggie@0.6.8 init --agent all
11
+ npx @topy-ai/maggie@0.6.9 init --agent all
12
12
  npx @topy-ai/maggie doctor --project .
13
13
  ```
14
14
 
@@ -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.2.0
5
+ version: 1.3.0
6
6
  ---
7
7
 
8
8
  # Maggie Content Localization
@@ -2,7 +2,7 @@
2
2
  name: maggie-deployment
3
3
  description: Deploy and operate Maggie blog projects with Cloudflare Workers as the default target, while preserving an adapter boundary for VPS, GCP, and AWS.
4
4
  metadata:
5
- version: 1.2.0
5
+ version: 1.3.0
6
6
  ---
7
7
 
8
8
  # Maggie Deployment
@@ -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.3.0
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.0.0
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.0.0
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
- candidate_paths={c["path"] for c in candidates}
797
- for service in data.get("services",[]):
798
- service["pages"]=[p for p in service.get("pages",[]) if p.get("role")!="supporting" or p.get("path") in candidate_paths]
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
- rows.append(f"<url><loc>{escape(item['url'])}</loc>{lastmod}</url>")
67
- return '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' + "".join(rows) + "</urlset>\n"
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", {})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",