@topy-ai/maggie 0.7.32 → 0.7.34

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.
Files changed (138) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +19 -4
  3. package/bundled-skills/maggie-blog-bootstrap/SKILL.md +8 -0
  4. package/bundled-skills/maggie-clone/SKILL.md +7 -1
  5. package/bundled-templates/astro-blog/README.md +217 -0
  6. package/bundled-templates/astro-blog/examples/blog.config.ts +30 -0
  7. package/bundled-templates/astro-blog/examples/content.config.ts +28 -0
  8. package/bundled-templates/astro-blog/examples/metadata.ts +37 -0
  9. package/bundled-templates/astro-blog/examples/ops-api.ts +31 -0
  10. package/bundled-templates/astro-blog/examples/route-manifest.ts +21 -0
  11. package/bundled-templates/astro-blog/starter/.env.example +36 -0
  12. package/bundled-templates/astro-blog/starter/README.md +168 -0
  13. package/bundled-templates/astro-blog/starter/astro.config.mjs +19 -0
  14. package/bundled-templates/astro-blog/starter/db/schema.sql +508 -0
  15. package/bundled-templates/astro-blog/starter/package-lock.json +8039 -0
  16. package/bundled-templates/astro-blog/starter/package.json +41 -0
  17. package/bundled-templates/astro-blog/starter/scripts/contract-check.mjs +16 -0
  18. package/bundled-templates/astro-blog/starter/scripts/fixture-seed.mjs +11 -0
  19. package/bundled-templates/astro-blog/starter/scripts/schema-check.mjs +13 -0
  20. package/bundled-templates/astro-blog/starter/scripts/seed-db.mjs +20 -0
  21. package/bundled-templates/astro-blog/starter/src/data/demo.ts +52 -0
  22. package/bundled-templates/astro-blog/starter/src/layouts/BaseLayout.astro +53 -0
  23. package/bundled-templates/astro-blog/starter/src/layouts/OpsLayout.astro +73 -0
  24. package/bundled-templates/astro-blog/starter/src/lib/ai-cmo.ts +14 -0
  25. package/bundled-templates/astro-blog/starter/src/lib/config.ts +15 -0
  26. package/bundled-templates/astro-blog/starter/src/lib/db.ts +95 -0
  27. package/bundled-templates/astro-blog/starter/src/lib/google.ts +28 -0
  28. package/bundled-templates/astro-blog/starter/src/lib/media.ts +12 -0
  29. package/bundled-templates/astro-blog/starter/src/lib/migration-validation.ts +11 -0
  30. package/bundled-templates/astro-blog/starter/src/lib/migration.ts +21 -0
  31. package/bundled-templates/astro-blog/starter/src/lib/ops-auth.ts +21 -0
  32. package/bundled-templates/astro-blog/starter/src/lib/providers/email.ts +45 -0
  33. package/bundled-templates/astro-blog/starter/src/lib/providers/media-storage.ts +37 -0
  34. package/bundled-templates/astro-blog/starter/src/lib/report-delivery.ts +26 -0
  35. package/bundled-templates/astro-blog/starter/src/lib/report-pdf.ts +49 -0
  36. package/bundled-templates/astro-blog/starter/src/lib/repository.ts +72 -0
  37. package/bundled-templates/astro-blog/starter/src/lib/seo-audit.ts +72 -0
  38. package/bundled-templates/astro-blog/starter/src/lib/seo-insights.ts +14 -0
  39. package/bundled-templates/astro-blog/starter/src/lib/seo.ts +33 -0
  40. package/bundled-templates/astro-blog/starter/src/lib/types.ts +58 -0
  41. package/bundled-templates/astro-blog/starter/src/lib/wordpress.ts +847 -0
  42. package/bundled-templates/astro-blog/starter/src/middleware.ts +7 -0
  43. package/bundled-templates/astro-blog/starter/src/pages/404.astro +11 -0
  44. package/bundled-templates/astro-blog/starter/src/pages/about.astro +15 -0
  45. package/bundled-templates/astro-blog/starter/src/pages/api/approval/[token].ts +25 -0
  46. package/bundled-templates/astro-blog/starter/src/pages/api/consent.ts +8 -0
  47. package/bundled-templates/astro-blog/starter/src/pages/api/ops/agency.ts +47 -0
  48. package/bundled-templates/astro-blog/starter/src/pages/api/ops/authors.ts +11 -0
  49. package/bundled-templates/astro-blog/starter/src/pages/api/ops/bulk.ts +58 -0
  50. package/bundled-templates/astro-blog/starter/src/pages/api/ops/calendar/publish-due.ts +12 -0
  51. package/bundled-templates/astro-blog/starter/src/pages/api/ops/config/validate.ts +4 -0
  52. package/bundled-templates/astro-blog/starter/src/pages/api/ops/content-tracking/report-state/batch.ts +19 -0
  53. package/bundled-templates/astro-blog/starter/src/pages/api/ops/content-tracking/report-state.ts +15 -0
  54. package/bundled-templates/astro-blog/starter/src/pages/api/ops/ctas.ts +5 -0
  55. package/bundled-templates/astro-blog/starter/src/pages/api/ops/entities.ts +23 -0
  56. package/bundled-templates/astro-blog/starter/src/pages/api/ops/media/optimize.ts +5 -0
  57. package/bundled-templates/astro-blog/starter/src/pages/api/ops/media/upload.ts +30 -0
  58. package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/[id]/resume.ts +28 -0
  59. package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/[id]/validate.ts +6 -0
  60. package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/[id].ts +7 -0
  61. package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/import.ts +458 -0
  62. package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/media.ts +5 -0
  63. package/bundled-templates/astro-blog/starter/src/pages/api/ops/operations.ts +189 -0
  64. package/bundled-templates/astro-blog/starter/src/pages/api/ops/playbooks/[id]/run.ts +55 -0
  65. package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/[id]/approve.ts +13 -0
  66. package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/[id]/preview.ts +9 -0
  67. package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/[id]/publish.ts +13 -0
  68. package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/[id].ts +18 -0
  69. package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/index.ts +28 -0
  70. package/bundled-templates/astro-blog/starter/src/pages/api/ops/providers.ts +6 -0
  71. package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/history.ts +3 -0
  72. package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/latest.ts +3 -0
  73. package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/posts.ts +3 -0
  74. package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/project-context.ts +4 -0
  75. package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/sync-updates.ts +43 -0
  76. package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/sync.ts +172 -0
  77. package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/updates.ts +3 -0
  78. package/bundled-templates/astro-blog/starter/src/pages/api/ops/redirects/check.ts +5 -0
  79. package/bundled-templates/astro-blog/starter/src/pages/api/ops/redirects.ts +32 -0
  80. package/bundled-templates/astro-blog/starter/src/pages/api/ops/reports/deliver-due.ts +12 -0
  81. package/bundled-templates/astro-blog/starter/src/pages/api/ops/reports/deliver.ts +6 -0
  82. package/bundled-templates/astro-blog/starter/src/pages/api/ops/reports/export.ts +17 -0
  83. package/bundled-templates/astro-blog/starter/src/pages/api/ops/reports.ts +31 -0
  84. package/bundled-templates/astro-blog/starter/src/pages/api/ops/rewrite/history/[contentId].ts +3 -0
  85. package/bundled-templates/astro-blog/starter/src/pages/api/ops/rewrite/policy.ts +4 -0
  86. package/bundled-templates/astro-blog/starter/src/pages/api/ops/rewrite/queue.ts +4 -0
  87. package/bundled-templates/astro-blog/starter/src/pages/api/ops/seo/audit.ts +7 -0
  88. package/bundled-templates/astro-blog/starter/src/pages/api/ops/seo/insights.ts +5 -0
  89. package/bundled-templates/astro-blog/starter/src/pages/api/ops/settings/site.ts +7 -0
  90. package/bundled-templates/astro-blog/starter/src/pages/api/ops/settings.ts +19 -0
  91. package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/auto-detect.ts +13 -0
  92. package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/diff.ts +11 -0
  93. package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/match.ts +16 -0
  94. package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/matching-history.ts +4 -0
  95. package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/runs.ts +4 -0
  96. package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap.ts +11 -0
  97. package/bundled-templates/astro-blog/starter/src/pages/api/ops/summary.ts +12 -0
  98. package/bundled-templates/astro-blog/starter/src/pages/api/ops/topics/[id]/faq.ts +12 -0
  99. package/bundled-templates/astro-blog/starter/src/pages/api/ops/topics.ts +11 -0
  100. package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/capabilities.ts +5 -0
  101. package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migration-plan.ts +21 -0
  102. package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migrations/[id]/resume.ts +5 -0
  103. package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migrations/[id]/validate.ts +6 -0
  104. package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migrations/[id].ts +13 -0
  105. package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migrations.ts +147 -0
  106. package/bundled-templates/astro-blog/starter/src/pages/authors/[slug].astro +12 -0
  107. package/bundled-templates/astro-blog/starter/src/pages/blog/[slug].astro +25 -0
  108. package/bundled-templates/astro-blog/starter/src/pages/blog/index.astro +32 -0
  109. package/bundled-templates/astro-blog/starter/src/pages/blog/page/[page].astro +21 -0
  110. package/bundled-templates/astro-blog/starter/src/pages/ops/index.astro +20 -0
  111. package/bundled-templates/astro-blog/starter/src/pages/ops/operations.astro +33 -0
  112. package/bundled-templates/astro-blog/starter/src/pages/ops/posts/[id]/edit.astro +17 -0
  113. package/bundled-templates/astro-blog/starter/src/pages/ops/posts/[id]/preview.astro +11 -0
  114. package/bundled-templates/astro-blog/starter/src/pages/ops/posts/index.astro +15 -0
  115. package/bundled-templates/astro-blog/starter/src/pages/ops/posts/new.astro +12 -0
  116. package/bundled-templates/astro-blog/starter/src/pages/ops/reports.astro +12 -0
  117. package/bundled-templates/astro-blog/starter/src/pages/ops/rewrite.astro +13 -0
  118. package/bundled-templates/astro-blog/starter/src/pages/ops/settings/integrations.astro +13 -0
  119. package/bundled-templates/astro-blog/starter/src/pages/ops/settings/site.astro +11 -0
  120. package/bundled-templates/astro-blog/starter/src/pages/ops/sitemap.astro +15 -0
  121. package/bundled-templates/astro-blog/starter/src/pages/ops/topics.astro +13 -0
  122. package/bundled-templates/astro-blog/starter/src/pages/ops/wordpress.astro +34 -0
  123. package/bundled-templates/astro-blog/starter/src/pages/review/[token].astro +13 -0
  124. package/bundled-templates/astro-blog/starter/src/pages/robots.txt.ts +4 -0
  125. package/bundled-templates/astro-blog/starter/src/pages/sitemap.xml.ts +12 -0
  126. package/bundled-templates/astro-blog/starter/src/pages/topics/[slug]/page/[page].astro +10 -0
  127. package/bundled-templates/astro-blog/starter/src/pages/topics/[slug].astro +19 -0
  128. package/bundled-templates/astro-blog/starter/src/pages/topics/index.astro +10 -0
  129. package/bundled-templates/astro-blog/starter/src/styles/global.css +4 -0
  130. package/bundled-templates/astro-blog/starter/tsconfig.json +9 -0
  131. package/bundled-templates/astro-blog/starter/wrangler.jsonc +52 -0
  132. package/bundled-tools/clis/maggie_clone.py +42 -15
  133. package/bundled-tools/clis/maggie_dash.py +41 -25
  134. package/bundled-tools/clis/maggie_design.py +2 -0
  135. package/bundled-tools/clis/maggie_design_system.py +7 -1
  136. package/bundled-tools/clis/site_audit.py +25 -2
  137. package/bundled-tools/runtime/maggie_memory.py +6 -1
  138. package/package.json +1 -1
@@ -0,0 +1,19 @@
1
+ ---
2
+ import BaseLayout from "../../layouts/BaseLayout.astro";
3
+ import { postsForTopic, siteUrl, topicBySlug } from "../../lib/repository";
4
+ import { topicJsonLd } from "../../lib/seo";
5
+ import type { Topic } from "../../lib/types";
6
+
7
+ const topic = topicBySlug(Astro.params.slug || "") as Topic | undefined;
8
+ if (!topic) { Astro.response.status = 404; return Astro.rewrite("/404"); }
9
+ const topicPosts = postsForTopic(topic.slug);
10
+ const pageSize = 12;
11
+ ---
12
+ <BaseLayout title={topic.title} description={topic.description} canonical={siteUrl(`/topics/${topic.slug}`)} jsonLd={topicJsonLd(topic, siteUrl(`/topics/${topic.slug}`))}>
13
+ <h1>{topic.title}</h1>
14
+ <p>{topic.description}</p>
15
+ <section><h2>Articles</h2>{topicPosts.slice(0, pageSize).map((post) => <article><h3><a href={`/blog/${post.slug}`}>{post.title}</a></h3><p>{post.excerpt}</p></article>)}</section>
16
+ {topicPosts.length > pageSize && <nav aria-label="Topic pagination"><a href={`/topics/${topic.slug}/page/2`}>Next</a></nav>}
17
+ <aside><h2>Keep learning</h2><a href="/get-started">Get started</a></aside>
18
+ {topic.faq.length > 0 && <section><h2>FAQs</h2>{topic.faq.map((faq) => <details><summary>{faq.question}</summary><p>{faq.answer}</p></details>)}</section>}
19
+ </BaseLayout>
@@ -0,0 +1,10 @@
1
+ ---
2
+ import BaseLayout from "../../layouts/BaseLayout.astro";
3
+ import { allTopics, siteUrl } from "../../lib/repository";
4
+ const topics = allTopics();
5
+ const publishedTopics = topics.filter((topic) => topic.status === "published");
6
+ ---
7
+ <BaseLayout title="Topics" description="Browse topics." canonical={siteUrl("/topics")} robots={publishedTopics.length ? "index,follow" : "noindex,follow"}>
8
+ <h1>Topics</h1>
9
+ <div class="topic-grid">{publishedTopics.map((topic) => <article><h2><a href={`/topics/${topic.slug}`}>{topic.title}</a></h2><p>{topic.description}</p></article>)}</div>
10
+ </BaseLayout>
@@ -0,0 +1,4 @@
1
+ @import "tailwindcss";
2
+
3
+ /* The starter keeps its contract UI framework-neutral while providing a
4
+ working Tailwind v4 entry point for projects that select Tailwind. */
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "astro/tsconfigs/strict",
3
+ "compilerOptions": {
4
+ "strictNullChecks": true,
5
+ "allowJs": false
6
+ },
7
+ "include": ["src"],
8
+ "exclude": ["node_modules", "dist", "_maggie", ".maggie"]
9
+ }
@@ -0,0 +1,52 @@
1
+ {
2
+ "$schema": "./node_modules/wrangler/config-schema.json",
3
+ "name": "maggie-blog",
4
+ "compatibility_date": "2026-08-27",
5
+ "compatibility_flags": ["nodejs_compat"],
6
+ "workers_dev": true,
7
+ "assets": {
8
+ "binding": "ASSETS",
9
+ "directory": "./dist"
10
+ },
11
+ "d1_databases": [
12
+ {
13
+ "binding": "DB",
14
+ "database_name": "maggie-blog"
15
+ }
16
+ ],
17
+ "r2_buckets": [
18
+ {
19
+ "binding": "MEDIA",
20
+ "bucket_name": "maggie-blog-media"
21
+ }
22
+ ],
23
+ "kv_namespaces": [
24
+ {
25
+ "binding": "CACHE"
26
+ }
27
+ ],
28
+ "env": {
29
+ "staging": {
30
+ "name": "maggie-blog-staging",
31
+ "workers_dev": true,
32
+ "vars": {
33
+ "PUBLIC_SITE_ENV": "staging",
34
+ "MAGGIE_DEPLOY_PROVIDER": "cloudflare"
35
+ },
36
+ "d1_databases": [{ "binding": "DB", "database_name": "maggie-blog-staging" }],
37
+ "r2_buckets": [{ "binding": "MEDIA", "bucket_name": "maggie-blog-media-staging" }],
38
+ "kv_namespaces": [{ "binding": "CACHE" }]
39
+ },
40
+ "production": {
41
+ "name": "maggie-blog-production",
42
+ "workers_dev": false,
43
+ "vars": {
44
+ "PUBLIC_SITE_ENV": "production",
45
+ "MAGGIE_DEPLOY_PROVIDER": "cloudflare"
46
+ },
47
+ "d1_databases": [{ "binding": "DB", "database_name": "maggie-blog" }],
48
+ "r2_buckets": [{ "binding": "MEDIA", "bucket_name": "maggie-blog-media" }],
49
+ "kv_namespaces": [{ "binding": "CACHE" }]
50
+ }
51
+ }
52
+ }
@@ -57,6 +57,12 @@ def normalized(url: str) -> str:
57
57
  def key_part(value, fallback):
58
58
  return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")[:48] or fallback
59
59
 
60
+ def css_urls(value: str) -> list[str]:
61
+ return [html.unescape(match).strip().strip("\"'") for match in re.findall(r"url\(\s*([^)]*?)\s*\)", value or "", re.I)]
62
+
63
+ def srcset_urls(value: str) -> list[str]:
64
+ return [part.strip().split()[0] for part in (value or "").split(",") if part.strip()]
65
+
60
66
  def plan(urls, project):
61
67
  result, seen = [], set()
62
68
  for raw in urls:
@@ -97,22 +103,26 @@ def rendered_quality(raw, parsed):
97
103
 
98
104
  class PageParser(HTMLParser):
99
105
  def __init__(self):
100
- super().__init__(); self.title = ""; self._title = False; self._heading = None; self._text = []; self.headings = []; self.links = []; self.images = []; self.stylesheets = []; self.metas = {}
106
+ super().__init__(); self.title = ""; self._title = False; self._heading = None; self._text = []; self._style = False; self._style_text = []; self.headings = []; self.links = []; self.images = []; self.backgrounds = []; self.stylesheets = []; self.metas = {}
101
107
  def handle_starttag(self, tag, attrs):
102
108
  values = dict(attrs)
103
109
  if tag == "title": self._title = True
110
+ elif tag == "style": self._style = True; self._style_text = []
104
111
  elif tag in {"h1", "h2", "h3", "h4", "h5", "h6"}: self._heading, self._text = tag, []
105
112
  elif tag == "a" and values.get("href"): self.links.append({"href": values["href"], "text": ""})
106
- elif tag == "img" and values.get("src"): self.images.append({"src": values["src"], "alt": values.get("alt", "") or ""})
113
+ elif tag in {"img", "source"} and (values.get("src") or values.get("srcset")): self.images.append({"src": values.get("src", ""), "srcset": values.get("srcset", ""), "alt": values.get("alt", "") or ""})
107
114
  elif tag == "link" and values.get("rel", "").lower() == "stylesheet" and values.get("href"): self.stylesheets.append(values["href"])
108
115
  elif tag == "meta":
109
116
  key = values.get("name") or values.get("property")
110
117
  if key and values.get("content") is not None: self.metas[key] = values["content"]
118
+ if values.get("style"): self.backgrounds.extend(css_urls(values["style"]))
111
119
  def handle_endtag(self, tag):
112
120
  if tag == "title": self._title = False
121
+ elif tag == "style": self.backgrounds.extend(css_urls("".join(self._style_text))); self._style = False; self._style_text = []
113
122
  elif tag == self._heading: self.headings.append({"tag": tag, "text": " ".join(self._text).strip()}); self._heading, self._text = None, []
114
123
  def handle_data(self, data):
115
124
  value = " ".join(data.split())
125
+ if self._style: self._style_text.append(data)
116
126
  if self._title: self.title += value
117
127
  if self._heading: self._text.append(value)
118
128
  if self.links and not self._heading: self.links[-1]["text"] = (self.links[-1]["text"] + " " + value).strip()
@@ -221,12 +231,17 @@ def command_extract(args):
221
231
  slug, pages = target["page_key"], []
222
232
  quality = []
223
233
  for name in VIEWPORTS:
224
- raw = (root / "html/source" / slug / f"{name}.html").read_text(encoding="utf-8"); parser = PageParser(); parser.feed(raw); pages.append({"viewport": name, "title": parser.title, "headings": parser.headings, "links": parser.links, "images": parser.images, "stylesheets": parser.stylesheets, "meta": parser.metas, "html_bytes": len(raw.encode())})
234
+ raw = (root / "html/source" / slug / f"{name}.html").read_text(encoding="utf-8"); parser = PageParser(); parser.feed(raw); pages.append({"viewport": name, "title": parser.title, "headings": parser.headings, "links": parser.links, "images": parser.images, "backgrounds": parser.backgrounds, "stylesheets": parser.stylesheets, "meta": parser.metas, "html_bytes": len(raw.encode())})
225
235
  quality.append({"viewport": name, **rendered_quality(raw, parser)})
226
236
  if not all(item["rendered"] for item in quality):
227
237
  raise RuntimeError(f"preview did not render content for {target['source_url']}; captured a loading/empty shell at one or more viewports")
228
238
  save_json(root / "extracted" / slug / "page.json", {"source_url": target["source_url"], "pages": pages})
229
- assets = [json.loads(x) for x in sorted({json.dumps(x, sort_keys=True) for page in pages for x in page["images"]})]
239
+ asset_records = []
240
+ for page in pages:
241
+ asset_records.extend({"src": image.get("src", ""), "kind": "image"} for image in page["images"] if image.get("src"))
242
+ asset_records.extend({"src": value, "kind": "responsive-image"} for image in page["images"] for value in srcset_urls(image.get("srcset", "")))
243
+ asset_records.extend({"src": value, "kind": "background"} for value in page.get("backgrounds", []))
244
+ assets = [json.loads(x) for x in sorted({json.dumps(x, sort_keys=True) for x in asset_records})]
230
245
  save_json(root / "extracted" / slug / "ASSET_MANIFEST.json", {"source_url": target["source_url"], "assets": assets})
231
246
  lines = [f"# {target['source_url']}", "", "## Headings", ""] + [f"- `{x['tag']}` {x['text']}" for x in pages[0]["headings"]] + ["", "## Evidence", "", "- DOM and screenshots are stored in the clone run.", "- Computed CSS and interaction states require browser evidence; do not infer them from classes."]
232
247
  (root / "extracted" / slug / "PAGE_TOPOLOGY.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
@@ -240,19 +255,31 @@ def command_assets(args):
240
255
  root, manifest = read_manifest(args.project.resolve(), args.run_id); require_phase(manifest, "extracted"); report = []
241
256
  for target in manifest["targets"]:
242
257
  slug = target["page_key"]; data = json.loads((root / "extracted" / slug / "page.json").read_text(encoding="utf-8")); seen = set()
258
+ references = []
243
259
  for page in data["pages"]:
244
- for asset in page["images"]:
245
- absolute = urllib.parse.urljoin(target.get("capture_url", target["source_url"]), html.unescape(asset["src"]))
246
- if absolute in seen: continue
247
- seen.add(absolute); item = {"url": absolute, "alt": asset.get("alt", ""), "status": "discovered"}
260
+ references.extend({"src": image.get("src", ""), "alt": image.get("alt", "")} for image in page["images"] if image.get("src"))
261
+ references.extend({"src": value, "alt": ""} for image in page["images"] for value in srcset_urls(image.get("srcset", "")))
262
+ references.extend({"src": value, "alt": ""} for value in page.get("backgrounds", []))
263
+ for stylesheet in page.get("stylesheets", []):
264
+ stylesheet_url = urllib.parse.urljoin(target.get("capture_url", target["source_url"]), html.unescape(stylesheet))
248
265
  try:
249
- with urllib.request.urlopen(urllib.request.Request(absolute, headers={"User-Agent": "MaggieClone/1.0"}), timeout=30) as response:
250
- body = response.read(args.max_bytes + 1); item.update({"status": "downloaded", "content_type": response.headers.get_content_type(), "bytes": len(body)})
251
- if len(body) <= args.max_bytes:
252
- suffix = Path(urllib.parse.urlsplit(absolute).path).suffix[:8]; destination = root / "assets" / slug / (hashlib.sha256(absolute.encode()).hexdigest()[:16] + suffix); destination.parent.mkdir(parents=True, exist_ok=True); destination.write_bytes(body); item["local_path"] = str(destination.relative_to(root))
253
- project_asset = Path(target["asset_root"]) / destination.name; project_asset.parent.mkdir(parents=True, exist_ok=True); shutil.copy2(destination, project_asset); item["project_path"] = str(project_asset)
254
- except (OSError, urllib.error.URLError) as error: item.update({"status": "failed", "error": str(error)})
255
- report.append(item)
266
+ with urllib.request.urlopen(urllib.request.Request(stylesheet_url, headers={"User-Agent": "MaggieClone/1.0"}), timeout=30) as response:
267
+ css = response.read(args.max_bytes).decode(response.headers.get_content_charset() or "utf-8", "replace")
268
+ references.extend({"src": value, "alt": ""} for value in css_urls(css))
269
+ except (OSError, urllib.error.URLError):
270
+ pass
271
+ for asset in references:
272
+ absolute = urllib.parse.urljoin(target.get("capture_url", target["source_url"]), html.unescape(asset["src"]))
273
+ if absolute in seen: continue
274
+ seen.add(absolute); item = {"url": absolute, "alt": asset.get("alt", ""), "status": "discovered"}
275
+ try:
276
+ with urllib.request.urlopen(urllib.request.Request(absolute, headers={"User-Agent": "MaggieClone/1.0"}), timeout=30) as response:
277
+ body = response.read(args.max_bytes + 1); item.update({"status": "downloaded", "content_type": response.headers.get_content_type(), "bytes": len(body)})
278
+ if len(body) <= args.max_bytes:
279
+ suffix = Path(urllib.parse.urlsplit(absolute).path).suffix[:8]; destination = root / "assets" / slug / (hashlib.sha256(absolute.encode()).hexdigest()[:16] + suffix); destination.parent.mkdir(parents=True, exist_ok=True); destination.write_bytes(body); item["local_path"] = str(destination.relative_to(root))
280
+ project_asset = Path(target["asset_root"]) / destination.name; project_asset.parent.mkdir(parents=True, exist_ok=True); shutil.copy2(destination, project_asset); item["project_path"] = str(project_asset)
281
+ except (OSError, urllib.error.URLError) as error: item.update({"status": "failed", "error": str(error)})
282
+ report.append(item)
256
283
  save_json(root / "assets" / "manifest.json", {"created_at": now(), "items": report}); set_phase(root, manifest, "assets"); print(f"asset audit: {len(report)} unique image(s), {sum(x['status'] == 'downloaded' for x in report)} downloaded"); return 0
257
284
 
258
285
  def command_compare(args):
@@ -40,6 +40,19 @@ def store_for(root: Path) -> MaggieDashStore:
40
40
  return MaggieDashStore(root / ".maggie" / "maggiedash.sqlite")
41
41
 
42
42
 
43
+ def resolve_project_id(args: argparse.Namespace) -> str:
44
+ """Use the persisted project identity when the CLI flag is omitted."""
45
+ explicit = getattr(args, "project_id", None)
46
+ if explicit:
47
+ return explicit
48
+ contract = project_root(args) / ".maggie" / "maggiedash.json"
49
+ try:
50
+ value = json.loads(contract.read_text(encoding="utf-8")).get("id")
51
+ except (OSError, json.JSONDecodeError, AttributeError):
52
+ value = None
53
+ return str(value).strip() if value else "local-project"
54
+
55
+
43
56
  def emit(value: object) -> None:
44
57
  print(json.dumps(value, indent=2, ensure_ascii=False))
45
58
 
@@ -296,10 +309,11 @@ def command_init(args: argparse.Namespace) -> int:
296
309
 
297
310
  def command_status(args: argparse.Namespace) -> int:
298
311
  root = project_root(args)
312
+ project_id = resolve_project_id(args)
299
313
  store = store_for(root)
300
314
  try:
301
- project = store.connection.execute("SELECT * FROM maggiedash_projects WHERE id=?", (args.project_id,)).fetchone()
302
- documents = store.connection.execute("SELECT id, kind, title, slug, status, updated_at FROM maggiedash_documents WHERE project_id=? ORDER BY updated_at DESC", (args.project_id,)).fetchall()
315
+ project = store.connection.execute("SELECT * FROM maggiedash_projects WHERE id=?", (project_id,)).fetchone()
316
+ documents = store.connection.execute("SELECT id, kind, title, slug, status, updated_at FROM maggiedash_documents WHERE project_id=? ORDER BY updated_at DESC", (project_id,)).fetchall()
303
317
  emit({"status": "ready" if project else "not_initialized", "project": dict(project) if project else None, "documents": [dict(row) for row in documents], "storage": str(store.path)})
304
318
  return 0 if project else 1
305
319
  finally:
@@ -321,9 +335,10 @@ def command_migrate(args: argparse.Namespace) -> int:
321
335
 
322
336
  def command_transition(args: argparse.Namespace) -> int:
323
337
  require_confirm(args)
338
+ project_id = resolve_project_id(args)
324
339
  store = store_for(project_root(args))
325
340
  try:
326
- emit(store.transition(args.project_id, args.document_id, args.status, args.actor, args.reason))
341
+ emit(store.transition(project_id, args.document_id, args.status, args.actor, args.reason))
327
342
  return 0
328
343
  finally:
329
344
  store.close()
@@ -331,31 +346,32 @@ def command_transition(args: argparse.Namespace) -> int:
331
346
 
332
347
  def command_cms(args: argparse.Namespace) -> int:
333
348
  require_confirm(args)
349
+ project_id = resolve_project_id(args)
334
350
  store = store_for(project_root(args))
335
351
  try:
336
352
  if args.cms_command == "revisions":
337
- result = store.list_revisions(args.project_id, args.document_id)
353
+ result = store.list_revisions(project_id, args.document_id)
338
354
  elif args.cms_command == "trash":
339
- result = store.trash(args.project_id, args.document_id, args.actor, args.reason)
355
+ result = store.trash(project_id, args.document_id, args.actor, args.reason)
340
356
  elif args.cms_command == "restore":
341
- result = store.restore(args.project_id, args.document_id, args.actor, args.reason)
357
+ result = store.restore(project_id, args.document_id, args.actor, args.reason)
342
358
  elif args.cms_command == "schedule":
343
- result = store.schedule_publish(args.project_id, args.document_id, args.publish_at, args.actor, args.reason)
359
+ result = store.schedule_publish(project_id, args.document_id, args.publish_at, args.actor, args.reason)
344
360
  elif args.cms_command == "publish-due":
345
- result = store.publish_due(args.project_id, args.now, args.actor, args.reason)
361
+ result = store.publish_due(project_id, args.now, args.actor, args.reason)
346
362
  elif args.cms_command == "duplicate":
347
- result = store.duplicate(args.project_id, args.document_id, args.new_id, args.new_slug, args.actor, args.reason)
363
+ result = store.duplicate(project_id, args.document_id, args.new_id, args.new_slug, args.actor, args.reason)
348
364
  elif args.cms_command == "redirect":
349
- result = store.add_redirect(args.project_id, args.from_path, args.to_path, args.actor, args.reason, args.status_code)
365
+ result = store.add_redirect(project_id, args.from_path, args.to_path, args.actor, args.reason, args.status_code)
350
366
  elif args.cms_command == "preview":
351
- result = store.issue_preview(args.project_id, args.document_id, args.secret, args.ttl)
367
+ result = store.issue_preview(project_id, args.document_id, args.secret, args.ttl)
352
368
  elif args.cms_command == "preset-save":
353
369
  sections = json.loads(Path(args.sections_file).resolve().read_text(encoding="utf-8"))
354
- result = store.save_section_preset(args.project_id, args.preset_id, args.name, sections, args.actor, args.description)
370
+ result = store.save_section_preset(project_id, args.preset_id, args.name, sections, args.actor, args.description)
355
371
  elif args.cms_command == "preset-list":
356
- result = store.list_section_presets(args.project_id)
372
+ result = store.list_section_presets(project_id)
357
373
  elif args.cms_command == "preset-insert":
358
- result = store.insert_section_preset(args.project_id, args.preset_id, args.existing_id, args.actor, args.reason)
374
+ result = store.insert_section_preset(project_id, args.preset_id, args.existing_id, args.actor, args.reason)
359
375
  else:
360
376
  raise ValueError(f"unknown CMS command: {args.cms_command}")
361
377
  emit(result)
@@ -622,7 +638,7 @@ def parser() -> argparse.ArgumentParser:
622
638
  install.set_defaults(func=command_install)
623
639
  status = sub.add_parser("status", help="inspect project and documents")
624
640
  status.add_argument("--project", default=".")
625
- status.add_argument("--project-id", default="local-project")
641
+ status.add_argument("--project-id")
626
642
  status.set_defaults(func=command_status)
627
643
  migrate = sub.add_parser("migrate", help="apply the local MaggieDash schema")
628
644
  migrate.add_argument("--project", default=".")
@@ -630,7 +646,7 @@ def parser() -> argparse.ArgumentParser:
630
646
  migrate.set_defaults(func=command_migrate)
631
647
  transition = sub.add_parser("transition", help="move a document through its approval lifecycle")
632
648
  transition.add_argument("--project", default=".")
633
- transition.add_argument("--project-id", default="local-project")
649
+ transition.add_argument("--project-id")
634
650
  transition.add_argument("--document-id", required=True)
635
651
  transition.add_argument("--status", required=True)
636
652
  transition.add_argument("--actor", default="cli")
@@ -641,24 +657,24 @@ def parser() -> argparse.ArgumentParser:
641
657
  cms_sub = cms.add_subparsers(dest="cms_command", required=True)
642
658
  for name in ("revisions", "trash", "restore"):
643
659
  command = cms_sub.add_parser(name)
644
- command.add_argument("--project", default="."); command.add_argument("--project-id", default="local-project")
660
+ command.add_argument("--project", default="."); command.add_argument("--project-id")
645
661
  command.add_argument("--document-id", required=True); command.add_argument("--actor", default="cli"); command.add_argument("--reason", default="CMS operation"); command.add_argument("--confirm", action="store_true")
646
662
  schedule = cms_sub.add_parser("schedule")
647
- schedule.add_argument("--project", default="."); schedule.add_argument("--project-id", default="local-project"); schedule.add_argument("--document-id", required=True); schedule.add_argument("--publish-at", required=True); schedule.add_argument("--actor", default="cli"); schedule.add_argument("--reason", required=True); schedule.add_argument("--confirm", action="store_true")
663
+ schedule.add_argument("--project", default="."); schedule.add_argument("--project-id"); schedule.add_argument("--document-id", required=True); schedule.add_argument("--publish-at", required=True); schedule.add_argument("--actor", default="cli"); schedule.add_argument("--reason", required=True); schedule.add_argument("--confirm", action="store_true")
648
664
  publish_due = cms_sub.add_parser("publish-due", help="publish approved scheduled content whose time has arrived")
649
- publish_due.add_argument("--project", default="."); publish_due.add_argument("--project-id", default="local-project"); publish_due.add_argument("--now"); publish_due.add_argument("--actor", default="scheduler"); publish_due.add_argument("--reason", default="scheduled publish tick"); publish_due.add_argument("--confirm", action="store_true")
665
+ publish_due.add_argument("--project", default="."); publish_due.add_argument("--project-id"); publish_due.add_argument("--now"); publish_due.add_argument("--actor", default="scheduler"); publish_due.add_argument("--reason", default="scheduled publish tick"); publish_due.add_argument("--confirm", action="store_true")
650
666
  duplicate = cms_sub.add_parser("duplicate")
651
- duplicate.add_argument("--project", default="."); duplicate.add_argument("--project-id", default="local-project"); duplicate.add_argument("--document-id", required=True); duplicate.add_argument("--new-id", required=True); duplicate.add_argument("--new-slug", required=True); duplicate.add_argument("--actor", default="cli"); duplicate.add_argument("--reason", required=True); duplicate.add_argument("--confirm", action="store_true")
667
+ duplicate.add_argument("--project", default="."); duplicate.add_argument("--project-id"); duplicate.add_argument("--document-id", required=True); duplicate.add_argument("--new-id", required=True); duplicate.add_argument("--new-slug", required=True); duplicate.add_argument("--actor", default="cli"); duplicate.add_argument("--reason", required=True); duplicate.add_argument("--confirm", action="store_true")
652
668
  redirect = cms_sub.add_parser("redirect")
653
- redirect.add_argument("--project", default="."); redirect.add_argument("--project-id", default="local-project"); redirect.add_argument("--from-path", required=True); redirect.add_argument("--to-path", required=True); redirect.add_argument("--status-code", type=int, default=301); redirect.add_argument("--actor", default="cli"); redirect.add_argument("--reason", required=True); redirect.add_argument("--confirm", action="store_true")
669
+ redirect.add_argument("--project", default="."); redirect.add_argument("--project-id"); redirect.add_argument("--from-path", required=True); redirect.add_argument("--to-path", required=True); redirect.add_argument("--status-code", type=int, default=301); redirect.add_argument("--actor", default="cli"); redirect.add_argument("--reason", required=True); redirect.add_argument("--confirm", action="store_true")
654
670
  preview = cms_sub.add_parser("preview")
655
- preview.add_argument("--project", default="."); preview.add_argument("--project-id", default="local-project"); preview.add_argument("--document-id", required=True); preview.add_argument("--secret", required=True); preview.add_argument("--ttl", type=int, default=900); preview.add_argument("--confirm", action="store_true")
671
+ preview.add_argument("--project", default="."); preview.add_argument("--project-id"); preview.add_argument("--document-id", required=True); preview.add_argument("--secret", required=True); preview.add_argument("--ttl", type=int, default=900); preview.add_argument("--confirm", action="store_true")
656
672
  preset_save = cms_sub.add_parser("preset-save", help="save a reusable ordered section arrangement")
657
- preset_save.add_argument("--project", default="."); preset_save.add_argument("--project-id", default="local-project"); preset_save.add_argument("--preset-id", required=True); preset_save.add_argument("--name", required=True); preset_save.add_argument("--sections-file", required=True); preset_save.add_argument("--description", default=""); preset_save.add_argument("--actor", default="cli"); preset_save.add_argument("--confirm", action="store_true")
673
+ preset_save.add_argument("--project", default="."); preset_save.add_argument("--project-id"); preset_save.add_argument("--preset-id", required=True); preset_save.add_argument("--name", required=True); preset_save.add_argument("--sections-file", required=True); preset_save.add_argument("--description", default=""); preset_save.add_argument("--actor", default="cli"); preset_save.add_argument("--confirm", action="store_true")
658
674
  preset_list = cms_sub.add_parser("preset-list", help="list reusable section arrangements")
659
- preset_list.add_argument("--project", default="."); preset_list.add_argument("--project-id", default="local-project"); preset_list.add_argument("--confirm", action="store_true")
675
+ preset_list.add_argument("--project", default="."); preset_list.add_argument("--project-id"); preset_list.add_argument("--confirm", action="store_true")
660
676
  preset_insert = cms_sub.add_parser("preset-insert", help="expand an arrangement with fresh section IDs")
661
- preset_insert.add_argument("--project", default="."); preset_insert.add_argument("--project-id", default="local-project"); preset_insert.add_argument("--preset-id", required=True); preset_insert.add_argument("--existing-id", action="append", default=[]); preset_insert.add_argument("--actor", default="cli"); preset_insert.add_argument("--reason", required=True); preset_insert.add_argument("--confirm", action="store_true")
677
+ preset_insert.add_argument("--project", default="."); preset_insert.add_argument("--project-id"); preset_insert.add_argument("--preset-id", required=True); preset_insert.add_argument("--existing-id", action="append", default=[]); preset_insert.add_argument("--actor", default="cli"); preset_insert.add_argument("--reason", required=True); preset_insert.add_argument("--confirm", action="store_true")
662
678
  cms.set_defaults(func=command_cms)
663
679
  ui = sub.add_parser("ui", help="validate MaggieDash dashboard chrome")
664
680
  ui_sub = ui.add_subparsers(dest="ui_command", required=True)
@@ -655,6 +655,8 @@ def in_place_job(project: Path, routes: list[str], force: bool = False, content_
655
655
  project / relative,
656
656
  project / "src" / "pages" / relative,
657
657
  project / "src" / "app" / relative,
658
+ project / "src" / "pages" / relative / "index.astro",
659
+ project / "src" / "pages" / relative / "index.html",
658
660
  project / "src" / "pages" / f"{relative}.astro",
659
661
  project / "src" / "pages" / f"{relative}.html",
660
662
  project / "src" / "app" / relative / "page.tsx",
@@ -91,7 +91,13 @@ def load_run(project: Path, run_id: str) -> tuple[Path, dict, list[dict]]:
91
91
 
92
92
 
93
93
  def sample_body(sample: Path) -> str:
94
- text = sample.read_text(encoding="utf-8")
94
+ try:
95
+ text = sample.read_text(encoding="utf-8")
96
+ except UnicodeDecodeError as error:
97
+ raise ValueError(
98
+ f"design sample must be a UTF-8 DESIGN.md/text file, not a binary image: {sample}; "
99
+ "use text metadata or convert the image into approved design tokens first"
100
+ ) from error
95
101
  return text.split("---", 2)[2].strip() if text.startswith("---") and text.count("---") >= 2 else text.strip()
96
102
 
97
103
 
@@ -18,6 +18,29 @@ from content_localization import MARKETS, parse_locale, valid_locale # noqa: E4
18
18
  import site_baseline
19
19
 
20
20
 
21
+ def stable_structure_reference(key: str, value: str) -> str:
22
+ """Ignore cache/content hashes in static asset references only."""
23
+ if key not in {"src", "href"} or not value:
24
+ return value
25
+ parsed = urlparse(value)
26
+ path = parsed.path
27
+ if not re.search(r"\.(?:css|js|mjs|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf)$", path, re.I):
28
+ return value
29
+ path = re.sub(r"(?:[-_.])[0-9a-f]{8,64}(?=\.[^.]+$)", "-asset", path, flags=re.I)
30
+ return urlunparse((parsed.scheme, parsed.netloc, path, parsed.params, "", ""))
31
+
32
+
33
+ def stable_structure(structure: list[list[object]]) -> list[list[object]]:
34
+ normalized = []
35
+ for entry in structure:
36
+ if len(entry) != 3 or not isinstance(entry[2], dict):
37
+ normalized.append(entry)
38
+ continue
39
+ attrs = {key: stable_structure_reference(key, str(value)) for key, value in entry[2].items()}
40
+ normalized.append([entry[0], entry[1], attrs])
41
+ return normalized
42
+
43
+
21
44
  class PageParser(HTMLParser):
22
45
  def __init__(self) -> None:
23
46
  super().__init__()
@@ -148,7 +171,7 @@ def audit_page(url: str, html: str, status: int, content_type: str, expected_lan
148
171
  "twitter_card": page.meta.get("twitter:card") in {"summary", "summary_large_image"},
149
172
  "jsonld": page.jsonld > 0 and all(item is not None for item in page.jsonld_values),
150
173
  "entity_jsonld": any(isinstance(item, dict) and item.get("@type") and (item.get("url") or item.get("@id")) for item in page.jsonld_values),
151
- "images": all(image["src"] and image["alt"] for image in page.images),
174
+ "images": all(image["src"] and image["alt"] is not None for image in page.images),
152
175
  "canonical_value": canonical,
153
176
  "image_count": len(page.images),
154
177
  "language": parse_locale(page.lang)[0],
@@ -161,7 +184,7 @@ def audit_page(url: str, html: str, status: int, content_type: str, expected_lan
161
184
  "title": page.title, "meta": page.meta, "canonical": canonical,
162
185
  "lang": page.lang, "hreflang": page.hreflang, "robots": robots,
163
186
  "jsonld": page.jsonld_values, "images": page.images,
164
- "structureHash": hashlib.sha256(json.dumps(page.structure, sort_keys=True).encode()).hexdigest(),
187
+ "structureHash": hashlib.sha256(json.dumps(stable_structure(page.structure), sort_keys=True).encode()).hexdigest(),
165
188
  "textHash": hashlib.sha256(" ".join(page.visible_text).encode()).hexdigest(),
166
189
  },
167
190
  "contentContract": {
@@ -21,7 +21,12 @@ FILES = {
21
21
  KINDS = set(FILES)
22
22
  SCOPES = {"user", "workspace", "project"}
23
23
  STATUSES = {"candidate", "active", "superseded", "archived"}
24
- SECRET_PATTERN = re.compile(r"(?:api[_-]?key|secret|token|password|private[_-]?key|oauth)", re.I)
24
+ # Reject credential-shaped assignments, not ordinary prose that happens to
25
+ # mention a security word (for example, "use design tokens").
26
+ SECRET_PATTERN = re.compile(
27
+ r"(?:api[_-]?key|secret|token|password|private[_-]?key|oauth)\s*(?:=|:)\s*[^\s,;]+",
28
+ re.I,
29
+ )
25
30
 
26
31
 
27
32
  def now() -> str:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.32",
3
+ "version": "0.7.34",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",