@topy-ai/maggie 0.7.9 → 0.7.10

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
@@ -218,8 +218,8 @@ artifact schemas.
218
218
  Recommended upgrade sequence for the current release:
219
219
 
220
220
  ```bash
221
- npx @topy-ai/maggie@0.7.9 update --project . --force
222
- npx @topy-ai/maggie@0.7.9 cleanup --project .
221
+ npx @topy-ai/maggie@0.7.10 update --project . --force
222
+ npx @topy-ai/maggie@0.7.10 cleanup --project .
223
223
  ```
224
224
 
225
225
  Maintainers should pass npm credentials through the repository helper, never
@@ -229,8 +229,11 @@ as a command-line argument:
229
229
  node scripts/publish-npm.mjs --maggie-env-file ../.env
230
230
  ```
231
231
 
232
- The 0.7.9 workflow adds nested section-field contracts, renderer-backed
233
- examples, stable-ID preservation during migration, and disjoint page
232
+ The 0.7.10 workflow adds served-content equivalence checks, query-route
233
+ baseline exclusions, changed-surface render evidence gates, generated skill
234
+ catalogs, component-binding audits, and icon-family noise filtering. It also
235
+ includes the 0.7.9 nested section-field contracts, renderer-backed examples,
236
+ stable-ID preservation during migration, and disjoint page
234
237
  inventory guidance. It retains the shared Google integrations runbook and
235
238
  fail-closed provider capability matrix, alongside the installable MaggieDash admin distribution and
236
239
  audited CMS operations (`cms revisions`,
@@ -470,7 +473,6 @@ python3 tools/clis/maggie_design.py rebrand \
470
473
  | `maggie-feedback` | Collect redacted feedback drafts and explicitly submit them to the NoBlox feedback endpoint |
471
474
  | `maggie-auth-reference` | Generate and validate traditional email/password auth with secure server-side sessions |
472
475
  | `maggie-blog` | Run a provider-neutral blog lifecycle with stable identity, topics, feeds, settings, and rollback |
473
- | `maggie-google-capabilities` | Validate redacted Google provider evidence and separate read/report/edit/publish capability states |
474
476
 
475
477
  `maggie-design` can initialize native blog and service UI plans from a local,
476
478
  read-only structural reference:
package/bin/maggie.js CHANGED
@@ -17,26 +17,18 @@ const MARKETPLACE_ROOT = join(PACKAGE_ROOT, "bundled-marketplace");
17
17
  const CONTRACTS_ROOT = join(PACKAGE_ROOT, "bundled-contracts");
18
18
  const TEMPLATES_ROOT = join(PACKAGE_ROOT, "bundled-templates");
19
19
  const STATE_DIR = ".maggie";
20
- const SKILL_NAMES = [
21
- "maggie-blog-bootstrap",
22
- "maggie-dash",
23
- "maggie-clone",
24
- "maggie-clone-to-template",
25
- "maggie-marketplace",
26
- "maggie-template",
27
- "maggie-design",
28
- "maggie-ops",
29
- "maggie-deployment",
30
- "maggie-project-context",
31
- "maggie-seo-geo",
32
- "maggie-social-share",
33
- "maggie-service-booking",
34
- "maggie-memory",
35
- "maggie-content-localization",
36
- "maggie-feedback",
37
- "maggie-auth-reference",
38
- "maggie-blog",
39
- ];
20
+ function loadSkillNames() {
21
+ try {
22
+ return JSON.parse(readFileSync(join(SKILLS_ROOT, "catalog.json"), "utf8")).skills.map((skill) => skill.name);
23
+ } catch {
24
+ // Local regression tests may invoke the wrapper before package assembly.
25
+ // Discover real frontmatter directories and never resurrect retired content.
26
+ return readdirSync(SKILLS_ROOT, { withFileTypes: true })
27
+ .filter((entry) => entry.isDirectory() && entry.name !== "maggie-emdash" && existsSync(join(SKILLS_ROOT, entry.name, "SKILL.md")))
28
+ .map((entry) => entry.name).sort();
29
+ }
30
+ }
31
+ const SKILL_NAMES = loadSkillNames();
40
32
  const RETIRED_PATHS = [
41
33
  ".agents/skills/maggie-emdash",
42
34
  ".claude/skills/maggie-emdash",
@@ -66,7 +58,8 @@ Usage:
66
58
  maggie doctor [--project PATH]
67
59
  maggie bootstrap interview [project]
68
60
  maggie dash init|install|status|migrate|transition|variant|cms --project PATH [options]
69
- maggie dash sections <validate|prompt|keys> [options]
61
+ maggie dash sections <validate|prompt|keys|remap-translations> [options]
62
+ maggie dash components-audit --bindings-file FILE --sections-file FILE --pages-file FILE
70
63
  maggie dash status --project PATH
71
64
  maggie dash migrate --project PATH --confirm
72
65
  maggie content FILE --source PROVIDER --project PATH --confirm
@@ -13,7 +13,6 @@ Skills are the agent-facing workflows. They compose with the tools in
13
13
  | `maggie-template` | Discover, recommend, inspect, and apply a selected Astro homepage template | marketplace catalog, bootstrap state, template `DESIGN.md` |
14
14
  | `maggie-design` | Fully clone authorized interior pages, then reconcile them to the homepage header/footer shell | browser MCP, clone planner CLI, completed Maggie homepage |
15
15
  | `maggie-ops` | Build, connect, operate, upgrade, and verify the private blog operations dashboard | Ops dashboard contract, authenticated API bridge, host tests |
16
- | `maggie-google-capabilities` | Validate a redacted Google provider capability matrix without inferring write access | Google integrations runbook, provider evidence |
17
16
  | `maggie-deployment` | Deploy and verify a dynamic Maggie blog, Cloudflare-first | Cloudflare Workers, D1, R2, KV, Wrangler |
18
17
  | `maggie-project-context` | Sync Project, voice, site and CTA context | project-context CLI/API |
19
18
  | `maggie-seo-geo` | Plan, audit, create/rewrite and measure SEO/GEO | visibility, SEO audit, GSC/GA4, content quality |
@@ -0,0 +1,78 @@
1
+ {
2
+ "schemaVersion": "maggie-skill-catalog.v1",
3
+ "generatedFrom": "skills/*/SKILL.md",
4
+ "skills": [
5
+ {
6
+ "name": "maggie-auth-reference",
7
+ "description": "Generate and validate a provider-neutral traditional email/password auth reference with secure server sessions and production security gates."
8
+ },
9
+ {
10
+ "name": "maggie-blog",
11
+ "description": "Create and operate a provider-neutral blog with stable post identity, topics, draft approval, archive/post routes, RSS, sitemap, settings, and idempotent local ingest."
12
+ },
13
+ {
14
+ "name": "maggie-blog-bootstrap",
15
+ "description": "Establish a MaggieDash-backed Astro blog or complete a small SEO/GEO-ready blog in an existing project."
16
+ },
17
+ {
18
+ "name": "maggie-clone",
19
+ "description": "Reverse-engineer an authorized website homepage and create the Maggie homepage foundation inside an existing blog project. Use with /maggie-clone plus a homepage URL when the user wants to replicate the homepage structure, header, footer, assets, responsive behavior, and interactions."
20
+ },
21
+ {
22
+ "name": "maggie-clone-to-template",
23
+ "description": "Turn an authorized website URL and a design or copy prompt into a validated marketplace template through clone, asset, screenshot, comparison, revision, and packaging gates."
24
+ },
25
+ {
26
+ "name": "maggie-content-localization",
27
+ "description": "Manage translation, polish, rewrite, market localization, review, stale detection, and publishing for pages, guides, posts, services, products, and categories."
28
+ },
29
+ {
30
+ "name": "maggie-dash",
31
+ "description": "Manage the MaggieDash project foundation, local content store, and approval lifecycle."
32
+ },
33
+ {
34
+ "name": "maggie-deployment",
35
+ "description": "Deploy and operate Maggie blog projects with Cloudflare Workers as the default target, while preserving an adapter boundary for VPS, GCP, and AWS."
36
+ },
37
+ {
38
+ "name": "maggie-design",
39
+ "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."
40
+ },
41
+ {
42
+ "name": "maggie-feedback",
43
+ "description": "Collect, review, and explicitly submit privacy-safe feedback about a Maggie skill run, workflow result, bug, or feature request."
44
+ },
45
+ {
46
+ "name": "maggie-marketplace",
47
+ "description": "Build, rebrand, localise, validate, version, and apply reusable Astro marketplace templates from an authorised preview URL or local HTML export. Use when adding a design template, creating a neutral demo, or preparing a stable homepage starter for an Astro project."
48
+ },
49
+ {
50
+ "name": "maggie-memory",
51
+ "description": "Persist and retrieve confirmed user preferences, project conventions, lessons from repaired mistakes, and structured error history across Maggie skill runs."
52
+ },
53
+ {
54
+ "name": "maggie-ops",
55
+ "description": "Build, connect, operate, upgrade, and verify a private Maggie Ops dashboard for blog content, API Pull, sitemap matching, rewrite approvals, reports, and integrations. Use when the user asks for Ops/admin functionality or lifecycle operations rather than public blog pages."
56
+ },
57
+ {
58
+ "name": "maggie-project-context",
59
+ "description": "Sync a site's safe AI CMO Project, selected brand voice, site settings, and CTA context into a local generated context file. Use when connecting a vibe-coded blog or existing website to AI CMO, refreshing brand context, or diagnosing missing CTA/project data."
60
+ },
61
+ {
62
+ "name": "maggie-seo-geo",
63
+ "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."
64
+ },
65
+ {
66
+ "name": "maggie-service-booking",
67
+ "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."
68
+ },
69
+ {
70
+ "name": "maggie-social-share",
71
+ "description": "Turn approved AI CMO articles and content assets into platform-native social drafts, scheduled posts, and measured distribution. Use for Social Share planning, LinkedIn/Facebook copy, CTA mapping, calendars, approvals, publish retries, or social performance readback."
72
+ },
73
+ {
74
+ "name": "maggie-template",
75
+ "description": "Discover, recommend, inspect, and apply verified Astro homepage templates from the Maggie marketplace. Use when a user wants to start a site from a selected template, compare available designs, or choose a visual direction before maggie-clone or maggie-design."
76
+ }
77
+ ]
78
+ }
@@ -177,6 +177,10 @@ maggie dash sections validate --registry templates/maggiedash/section-registry.j
177
177
  maggie dash sections prompt --registry templates/maggiedash/section-registry.json
178
178
  maggie dash sections keys --registry templates/maggiedash/section-registry.json \
179
179
  --page-id <page-id> --sections-file .maggie/sections.json
180
+ maggie dash sections remap-translations \
181
+ --translations-file .maggie/translations-legacy.json \
182
+ --mapping-file .maggie/section-translation-map.json \
183
+ --output .maggie/translations-v2.json --confirm
180
184
  ```
181
185
 
182
186
  The catalogue declares purpose, usage, placement, repeatability and layout
@@ -184,10 +188,15 @@ limits, renderer-owned examples, and the shape of repeated entries. A repeat
184
188
  may contain an object (`title`, `body`, `href`, and so on), not just a count;
185
189
  the nested limits are displayed to the planner and checked by `sections
186
190
  validate`. Over-limit copy is an editor note; unknown types fail validation.
187
- The starter registry is provider-neutral and extensible: a host may add its
188
- own renderer-backed bands, but every added band must have a real-renderer
189
- preview or an explicitly labelled example fallback at the supported
190
- breakpoints. Do not describe the vocabulary as a fixed number of bands.
191
+ The registry is intentionally marked `catalogStatus: starter`, not presented
192
+ as a production-complete vocabulary. Hosts commonly add renderer-backed bands
193
+ such as `form`, `map`, `steps`, `checklist`, `compare`, `quote`, `timeline`,
194
+ `gallery`, `stats`, `split`, `glossary`, `credentials`, `team`, `before-after`,
195
+ `statement`, `features`, `scope`, `animated`, `opening`, `breadcrumb`, and
196
+ `bound-collection`; the machine-readable registry lists these examples. Every
197
+ added band must have a real-renderer preview or an explicitly labelled example
198
+ fallback at the supported breakpoints. Do not describe the starter vocabulary
199
+ as a fixed number of bands.
191
200
 
192
201
  Translation keys use stable section IDs, with an array-index fallback only
193
202
  until the ordered migration is complete. Migration preserves unique IDs that
@@ -195,6 +204,13 @@ already belong to the source section list; it generates a new ID only for a
195
204
  missing, duplicate, or target-conflicting ID. Never rebuild IDs from array
196
205
  position or overwrite existing translation keys.
197
206
 
207
+ When copy already lives under another keyspace, generate an explicit prefix
208
+ mapping from the converter and run `remap-translations`. The helper applies
209
+ longest prefixes first, preserves unmapped keys for review, and fails on
210
+ conflicting destinations. For a database adapter, apply the section rows and
211
+ translation remap in one transaction with the same migration ID; the JSON CLI
212
+ is the reviewable output form, not a substitute for the adapter's transaction.
213
+
198
214
  Keep data-owned values out of page copy. For example, a pricing band should
199
215
  store a service identifier or slug and let the live service/booking adapter
200
216
  render current options and prices. Inventory reports must classify each
@@ -30,6 +30,12 @@ errors, missing assets, or visual placeholders. The result follows
30
30
  `maggie-deployment-canary.v1`; never include cookies, authorization headers, or
31
31
  response bodies in it.
32
32
 
33
+ When a release changes a visitor-facing HTML, CSS, component, or asset surface,
34
+ the release preflight automatically requires both a passing icon inventory and
35
+ rendered canary evidence. The canary must include screenshots, zero console or
36
+ network errors, and zero placeholder matches. Query-driven routes belong in
37
+ behavior/API checks, not static byte baselines.
38
+
33
39
  ## Automatic memory hook
34
40
 
35
41
  Follow [Maggie Memory Hook](../../references/memory-hook.md) at invocation and completion.
@@ -34,8 +34,10 @@ Use `missing`, `unknown`, and `coverage` from the versioned
34
34
  `maggie-icon-inventory.v1` report as the release gate. A binary font without a
35
35
  name map is reported as evidence only; it must not be treated as proof that a
36
36
  glyph exists. Fix the source token or add the runtime definition before
37
- shipping. Do not paste private source, URLs with credentials, or user data
38
- into the report.
37
+ shipping. The inventory compares a scoped runtime icon family (`ph-*`, `fa-*`,
38
+ or `lucide-*`), normalizes family prefixes, and ignores inline SVG IDs and font
39
+ filename noise. Do not paste private source, URLs with credentials, or user
40
+ data into the report.
39
41
 
40
42
  ## Automatic memory hook
41
43
 
@@ -83,8 +83,13 @@ automatically replace the baseline following failure. Dynamic dates, class
83
83
  names and copy can produce legitimate differences requiring review.
84
84
 
85
85
  This covers server-rendered sitemap pages, not CSS rendering, JavaScript-only
86
- content, database translation keys or browser interactions. A source key change
87
- is detected here only when it changes served content. Baselines contain site
86
+ content, database translation keys or browser interactions. Query-string URLs
87
+ are recorded as excluded from byte baselines because they often represent
88
+ search/filter state; use browser or API-specific tests for those states. The
89
+ baseline now compares a served-content contract (headings, paragraphs and
90
+ links) alongside metadata and structure, so a section migration cannot pass
91
+ merely because the HTML byte shape stayed similar. A source key change is
92
+ detected here only when it changes served content. Baselines contain site
88
93
  metadata; do not publish private project contracts without permission.
89
94
 
90
95
  ## Browser behavior evidence
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "schemaVersion": "maggiedash-section-registry.v1",
3
3
  "description": "The provider-neutral starter vocabulary used by page planners and editors. Hosts may extend it with renderer-backed section types.",
4
+ "catalogStatus": "starter",
5
+ "hostExtensionExamples": ["form", "map", "steps", "checklist", "compare", "quote", "timeline", "gallery", "stats", "split", "glossary", "credentials", "team", "before-after", "statement", "features", "scope", "animated", "opening", "breadcrumb", "bound-collection"],
4
6
  "sections": [
5
7
  {
6
8
  "type": "hero",
@@ -23,7 +23,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
23
23
  from maggie_dash_store import MaggieDashStore # noqa: E402
24
24
  from service_variants import ServiceVariantStore # noqa: E402
25
25
  from maggie_dash_ui import load_and_validate # noqa: E402
26
- from maggie_sections import catalogue, section_id_migration, validate_registry, copy_notes # noqa: E402
26
+ from maggie_sections import catalogue, remap_translations, section_id_migration, validate_registry, copy_notes # noqa: E402
27
+ from route_imports import classify_bindings # noqa: E402
27
28
 
28
29
 
29
30
  def project_root(args: argparse.Namespace) -> Path:
@@ -319,6 +320,20 @@ def command_ui(args: argparse.Namespace) -> int:
319
320
 
320
321
 
321
322
  def command_sections(args: argparse.Namespace) -> int:
323
+ if args.sections_command == "remap-translations":
324
+ require_confirm(args)
325
+ translations = json.loads(Path(args.translations_file).resolve().read_text(encoding="utf-8"))
326
+ mappings = json.loads(Path(args.mapping_file).resolve().read_text(encoding="utf-8"))
327
+ result = remap_translations(translations, mappings)
328
+ if result["passed"]:
329
+ output = Path(args.output).resolve()
330
+ if output.exists() and not args.force:
331
+ raise ValueError(f"output exists; use --force to replace: {output}")
332
+ output.parent.mkdir(parents=True, exist_ok=True)
333
+ output.write_text(json.dumps(result["translations"], indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
334
+ result["output"] = str(output)
335
+ emit(result)
336
+ return 0 if result["passed"] else 1
322
337
  registry = json.loads(Path(args.registry).resolve().read_text(encoding="utf-8"))
323
338
  checked = validate_registry(registry)
324
339
  if not checked["passed"]:
@@ -337,6 +352,23 @@ def command_sections(args: argparse.Namespace) -> int:
337
352
  return 0
338
353
 
339
354
 
355
+ def command_components_audit(args: argparse.Namespace) -> int:
356
+ bindings = json.loads(Path(args.bindings_file).resolve().read_text(encoding="utf-8"))
357
+ sections = json.loads(Path(args.sections_file).resolve().read_text(encoding="utf-8"))
358
+ pages = json.loads(Path(args.pages_file).resolve().read_text(encoding="utf-8"))
359
+ result = classify_bindings(
360
+ bindings if isinstance(bindings, list) else bindings.get("bindings", []),
361
+ set(sections if isinstance(sections, list) else sections.get("paths", [])),
362
+ set(pages if isinstance(pages, list) else pages.get("paths", [])),
363
+ )
364
+ if args.output:
365
+ output = Path(args.output).resolve(); output.parent.mkdir(parents=True, exist_ok=True)
366
+ output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
367
+ result["output"] = str(output)
368
+ emit(result)
369
+ return 0 if result["passed"] else 1
370
+
371
+
340
372
  def variant_store(args: argparse.Namespace) -> ServiceVariantStore:
341
373
  return ServiceVariantStore(project_root(args) / ".maggie" / "service-variants.json")
342
374
 
@@ -449,7 +481,16 @@ def parser() -> argparse.ArgumentParser:
449
481
  sections_prompt.set_defaults(func=command_sections)
450
482
  sections_keys = sections_sub.add_parser("keys")
451
483
  sections_keys.add_argument("--registry", required=True); sections_keys.add_argument("--sections-file", required=True); sections_keys.add_argument("--page-id", required=True)
484
+ sections_remap = sections_sub.add_parser("remap-translations", help="move an existing translation keyspace into stable section keys")
485
+ sections_remap.add_argument("--translations-file", required=True); sections_remap.add_argument("--mapping-file", required=True); sections_remap.add_argument("--output", required=True); sections_remap.add_argument("--force", action="store_true"); sections_remap.add_argument("--confirm", action="store_true")
486
+ sections_remap.set_defaults(func=command_sections)
452
487
  sections_keys.set_defaults(func=command_sections)
488
+ components = sub.add_parser("components-audit", help="classify route component bindings against live section/page inventories")
489
+ components.add_argument("--bindings-file", required=True)
490
+ components.add_argument("--sections-file", required=True, help="JSON array/object of section-managed paths")
491
+ components.add_argument("--pages-file", required=True, help="JSON array/object of live page paths")
492
+ components.add_argument("--output")
493
+ components.set_defaults(func=command_components_audit)
453
494
  variant = sub.add_parser("variant", help="manage service variant lifecycle")
454
495
  variant_sub = variant.add_subparsers(dest="variant_command", required=True)
455
496
  create = variant_sub.add_parser("create"); create.add_argument("--project", default="."); create.add_argument("--service-id", required=True); create.add_argument("--variant-id", required=True); create.add_argument("--variant-type", required=True); create.add_argument("--locale", required=True); create.add_argument("--market", required=True); create.add_argument("--slug", required=True); create.add_argument("--title", required=True); create.add_argument("--facts", required=True); create.add_argument("--source-revision", required=True); create.add_argument("--canonical-variant-id"); create.add_argument("--cluster-link", action="append", default=[]); create.add_argument("--layout-family", default="service-default"); create.add_argument("--confirm", action="store_true")
@@ -10,11 +10,19 @@ from pathlib import Path
10
10
 
11
11
  SOURCE_EXTENSIONS = {".astro", ".css", ".html", ".jsx", ".js", ".json", ".svelte", ".tsx", ".ts", ".vue"}
12
12
  IGNORE_NAMES = {"icon", "icons", "true", "false", "null", "undefined"}
13
+ NOISE_NAME = re.compile(r"^(?:inline-)?svg[-_]\d+$|^(?:solid|regular|brands|light|thin|duotone)[-_]\d+$|^\d+$", re.I)
14
+
15
+
16
+ def normalize_name(name: str) -> str:
17
+ """Return the family-neutral name used for source/runtime comparison."""
18
+ value = name.strip().lower().replace("/", ":")
19
+ value = re.sub(r"^(?:phosphor|ph|font-awesome|fa|lucide):", "", value)
20
+ return value
13
21
 
14
22
 
15
23
  def _add(found: dict[str, set[str]], name: str, file: Path, evidence: str) -> None:
16
- name = name.strip().lower()
17
- if not name or name in IGNORE_NAMES or len(name) > 80 or not re.fullmatch(r"[a-z0-9][a-z0-9._:/-]*", name):
24
+ name = normalize_name(name)
25
+ if not name or name in IGNORE_NAMES or NOISE_NAME.fullmatch(name) or len(name) > 80 or not re.fullmatch(r"[a-z0-9][a-z0-9._:-]*", name):
18
26
  return
19
27
  found.setdefault(name, set()).add(f"{file.as_posix()}:{evidence}")
20
28
 
@@ -31,7 +39,7 @@ def source_icons(root: Path, source_dir: Path) -> dict[str, set[str]]:
31
39
  _add(found, match.group(1), relative, "named-property")
32
40
  for match in re.finditer(r"data-(?:icon|glyph)\s*=\s*[\"']([^\"']+)", text, re.I):
33
41
  _add(found, match.group(1), relative, "data-attribute")
34
- for match in re.finditer(r"(?:ph|fa|lucide|icon)-([a-z0-9][a-z0-9-]*)", text, re.I):
42
+ for match in re.finditer(r"(?:ph|fa|lucide)-([a-z0-9][a-z0-9-]*)", text, re.I):
35
43
  _add(found, match.group(1), relative, "icon-class")
36
44
  for match in re.finditer(r"(?:phosphor|lucide|icon)[:/]([a-z0-9][a-z0-9-]*)", text, re.I):
37
45
  _add(found, match.group(1), relative, "icon-token")
@@ -49,10 +57,14 @@ def runtime_icons(root: Path, runtime_paths: list[Path]) -> tuple[set[str], dict
49
57
  continue
50
58
  text = path.read_text(encoding="utf-8", errors="replace")
51
59
  relative = path.relative_to(root.resolve()) if path.is_relative_to(root.resolve()) else path
52
- for match in re.finditer(r"\.(?:ph|fa|lucide|icon)-([a-z0-9][a-z0-9-]*)\b", text, re.I):
53
- name = match.group(1).lower(); names.add(name); evidence.setdefault(name, set()).add(f"{relative}:css-class")
60
+ for match in re.finditer(r"\.(?:ph|fa|lucide)-([a-z0-9][a-z0-9-]*)\b", text, re.I):
61
+ name = normalize_name(match.group(1))
62
+ if not NOISE_NAME.fullmatch(name):
63
+ names.add(name); evidence.setdefault(name, set()).add(f"{relative}:css-class")
54
64
  for match in re.finditer(r"(?:data-(?:icon|glyph)|icon)\s*=\s*[\"']([^\"']+)", text, re.I):
55
- name = match.group(1).lower(); names.add(name); evidence.setdefault(name, set()).add(f"{relative}:runtime-map")
65
+ name = normalize_name(match.group(1))
66
+ if name not in IGNORE_NAMES and not NOISE_NAME.fullmatch(name):
67
+ names.add(name); evidence.setdefault(name, set()).add(f"{relative}:runtime-map")
56
68
  return names, evidence, font_files
57
69
 
58
70
 
@@ -127,6 +127,60 @@ def evidence_gate(project: Path, environment: str) -> dict:
127
127
  return {"name": "durable-site-evidence", "passed": not errors, "exitCode": 0 if not errors else 1, "result": {"passed": not errors, "errors": errors, "evidence": summaries}, "stderr": ""}
128
128
 
129
129
 
130
+ def changed_surface_gate(project: Path) -> dict:
131
+ """Require visual/runtime evidence when a visitor-facing surface changed."""
132
+ try:
133
+ changed = subprocess.run(
134
+ ["git", "-C", str(project), "diff", "--name-only"],
135
+ capture_output=True, text=True, check=True,
136
+ ).stdout.splitlines()
137
+ except (OSError, subprocess.CalledProcessError):
138
+ return {"name": "changed-surface-evidence", "passed": True, "exitCode": 0,
139
+ "result": {"passed": True, "changed": False, "reason": "project is not a git worktree"}, "stderr": ""}
140
+ visitor_suffixes = {".astro", ".css", ".scss", ".html", ".jsx", ".tsx", ".js", ".ts", ".svg", ".png", ".jpg", ".jpeg", ".webp"}
141
+ surfaces = sorted(path for path in changed if Path(path).suffix.lower() in visitor_suffixes)
142
+ if not surfaces:
143
+ return {"name": "changed-surface-evidence", "passed": True, "exitCode": 0,
144
+ "result": {"passed": True, "changed": False, "surfaces": []}, "stderr": ""}
145
+
146
+ candidates = {
147
+ "iconInventory": (project / "docs/icon-inventory.json", project / ".maggie/icon-inventory.json"),
148
+ "renderEvidence": (project / "docs/deployment-canary.json", project / ".maggie/deployment-canary.json", project / "docs/rendered-canary.json", project / ".maggie/rendered-canary.json"),
149
+ }
150
+ errors: list[str] = []
151
+ evidence: dict[str, dict[str, object]] = {}
152
+ for name, paths in candidates.items():
153
+ path = next((candidate for candidate in paths if candidate.exists()), None)
154
+ if path is None:
155
+ errors.append(f"missing {name} evidence")
156
+ continue
157
+ try:
158
+ payload = json.loads(path.read_text(encoding="utf-8"))
159
+ except (OSError, json.JSONDecodeError) as exc:
160
+ errors.append(f"{name}: {exc}")
161
+ continue
162
+ passed = payload.get("passed") is True or payload.get("status") in {"pass", "passed"}
163
+ evidence[name] = {"path": str(path.relative_to(project)), "passed": passed}
164
+ if not passed:
165
+ errors.append(f"{name} is not passed")
166
+ if name == "renderEvidence":
167
+ render = payload.get("render", payload)
168
+ screenshots = render.get("screenshots", render.get("screenshotEvidence", [])) if isinstance(render, dict) else []
169
+ console_errors = render.get("consoleErrors", []) if isinstance(render, dict) else []
170
+ network_errors = render.get("networkErrors", []) if isinstance(render, dict) else []
171
+ placeholders = render.get("placeholderMatches", render.get("placeholders", [])) if isinstance(render, dict) else []
172
+ if not screenshots:
173
+ errors.append("renderEvidence has no screenshot evidence")
174
+ if console_errors:
175
+ errors.append("renderEvidence contains console errors")
176
+ if network_errors:
177
+ errors.append("renderEvidence contains network errors")
178
+ if placeholders:
179
+ errors.append("renderEvidence contains placeholder matches")
180
+ return {"name": "changed-surface-evidence", "passed": not errors, "exitCode": 0 if not errors else 1,
181
+ "result": {"passed": not errors, "changed": True, "surfaces": surfaces, "evidence": evidence, "errors": errors}, "stderr": ""}
182
+
183
+
130
184
  def editorial_gate(project: Path) -> dict:
131
185
  """Require explicit editorial approval for every launch category.
132
186
 
@@ -224,6 +278,7 @@ def main() -> int:
224
278
  parser.error(f"project directory does not exist: {project}")
225
279
 
226
280
  gates = []
281
+ gates.append(changed_surface_gate(project))
227
282
  gates.append(build_gate(project))
228
283
  for name, command in build_gates(project, args.environment, args.target, not args.skip_compatibility):
229
284
  gates.append(run_gate(name, command, project))
@@ -9,9 +9,8 @@ import json
9
9
  import re
10
10
  import sys
11
11
  from pathlib import Path
12
- from urllib.parse import urljoin
13
- from html.parser import HTMLParser
14
12
  from urllib.parse import urljoin, urlparse
13
+ from html.parser import HTMLParser
15
14
  from urllib.request import Request, urlopen
16
15
 
17
16
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
@@ -39,6 +38,10 @@ class PageParser(HTMLParser):
39
38
  self.visible_text = []
40
39
  self.hidden_depth = 0
41
40
  self.templates = set()
41
+ self._content_capture = []
42
+ self.headings = []
43
+ self.paragraphs = []
44
+ self.links = []
42
45
 
43
46
  def handle_starttag(self, tag, attrs):
44
47
  data = dict(attrs)
@@ -49,6 +52,8 @@ class PageParser(HTMLParser):
49
52
  if not self.hidden_depth:
50
53
  self.structure.append(["start", tag, {key: data[key] for key in
51
54
  ("class", "id", "role", "href", "src", "data-template", "data-section", "data-i18n") if key in data}])
55
+ if tag in {"h1", "h2", "h3", "h4", "h5", "h6", "p", "a"}:
56
+ self._content_capture.append({"tag": tag, "parts": [], "href": data.get("href", "")})
52
57
  if tag == "html":
53
58
  self.lang = data.get("lang", "")
54
59
  if tag == "meta" and data.get("name"):
@@ -74,6 +79,15 @@ class PageParser(HTMLParser):
74
79
  self.images.append({"src": data.get("src", ""), "alt": data.get("alt")})
75
80
 
76
81
  def handle_endtag(self, tag):
82
+ if not self.hidden_depth and self._content_capture and self._content_capture[-1]["tag"] == tag:
83
+ capture = self._content_capture.pop()
84
+ text = " ".join("".join(capture["parts"]).split())
85
+ if text and tag.startswith("h"):
86
+ self.headings.append(text)
87
+ elif text and tag == "p":
88
+ self.paragraphs.append(text)
89
+ elif tag == "a" and capture.get("href"):
90
+ self.links.append({"href": capture["href"], "text": text})
77
91
  if tag in {"script", "style", "noscript"}:
78
92
  self.hidden_depth = max(0, self.hidden_depth - 1)
79
93
  elif not self.hidden_depth:
@@ -91,6 +105,8 @@ class PageParser(HTMLParser):
91
105
  def handle_data(self, data):
92
106
  if not self.hidden_depth and data.strip():
93
107
  self.visible_text.append(" ".join(data.split()))
108
+ for capture in self._content_capture:
109
+ capture["parts"].append(data)
94
110
  if self.in_title:
95
111
  self.title += data.strip()
96
112
  if self._jsonld is not None:
@@ -148,6 +164,12 @@ def audit_page(url: str, html: str, status: int, content_type: str, expected_lan
148
164
  "structureHash": hashlib.sha256(json.dumps(page.structure, sort_keys=True).encode()).hexdigest(),
149
165
  "textHash": hashlib.sha256(" ".join(page.visible_text).encode()).hexdigest(),
150
166
  },
167
+ "contentContract": {
168
+ "headings": page.headings,
169
+ "paragraphs": page.paragraphs,
170
+ "links": [{"href": urljoin(url, link["href"]), "text": link["text"]} for link in page.links],
171
+ "contentHash": hashlib.sha256(json.dumps({"headings": page.headings, "paragraphs": page.paragraphs, "links": page.links}, sort_keys=True, ensure_ascii=False).encode()).hexdigest(),
172
+ },
151
173
  "robots": not robots or not any(token in {"noindex", "none", "nofollow"} for token in robots_tokens),
152
174
  "robots_directives": robots,
153
175
  "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,
@@ -12,6 +12,7 @@ from typing import Any, Iterable
12
12
 
13
13
  SCHEMA = "maggiedash-section-registry.v1"
14
14
  IDENTITY_SCHEMA = "maggiedash-section-identity.v1"
15
+ TRANSLATION_REMAP_SCHEMA = "maggiedash-translation-remap.v1"
15
16
 
16
17
 
17
18
  def _validate_field(field: object, at: str, errors: list[str], *, nested: bool = False) -> None:
@@ -191,3 +192,37 @@ def section_id_migration(page_id: str, sections: list[dict[str, Any]]) -> dict[s
191
192
  identified = ensure_section_ids(sections)
192
193
  mappings = [{"from": f"page.{page_id}.sections.{index}.", "to": f"page.{page_id}.sections.{section['id']}."} for index, section in enumerate(identified)]
193
194
  return {"schemaVersion": IDENTITY_SCHEMA, "pageId": page_id, "sections": identified, "renameOrder": sorted(mappings, key=lambda item: len(item["from"]), reverse=True), "sourceChecksum": hashlib.sha256(json.dumps(sections, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode()).hexdigest()}
195
+
196
+
197
+ def remap_translations(translations: object, mappings: object) -> dict[str, Any]:
198
+ """Move an existing translation keyspace without dropping unmapped keys."""
199
+ if not isinstance(translations, dict):
200
+ return {"schemaVersion": TRANSLATION_REMAP_SCHEMA, "passed": False, "errors": ["translations must be an object"]}
201
+ if isinstance(mappings, dict):
202
+ mappings = mappings.get("renameOrder") or mappings.get("mappings")
203
+ if not isinstance(mappings, list) or not mappings:
204
+ return {"schemaVersion": TRANSLATION_REMAP_SCHEMA, "passed": False, "errors": ["mappings must be a non-empty list"]}
205
+ valid = [item for item in mappings if isinstance(item, dict) and isinstance(item.get("from"), str) and isinstance(item.get("to"), str) and item["from"] and item["to"]]
206
+ if len(valid) != len(mappings):
207
+ return {"schemaVersion": TRANSLATION_REMAP_SCHEMA, "passed": False, "errors": ["every mapping needs non-empty from and to prefixes"]}
208
+ ordered = sorted(valid, key=lambda item: len(item["from"]), reverse=True)
209
+ result: dict[str, Any] = {}
210
+ applied: list[dict[str, str]] = []
211
+ unmapped: list[str] = []
212
+ collisions: list[str] = []
213
+ for key, value in translations.items():
214
+ source = str(key)
215
+ destination = next((item["to"] + source[len(item["from"]):] for item in ordered if source.startswith(item["from"])), None)
216
+ if destination is None:
217
+ destination = source
218
+ unmapped.append(source)
219
+ elif destination != source:
220
+ applied.append({"from": source, "to": destination})
221
+ if destination in result and result[destination] != value:
222
+ collisions.append(destination)
223
+ continue
224
+ result[destination] = value
225
+ errors = [f"translation key collision: {key}" for key in sorted(set(collisions))]
226
+ return {"schemaVersion": TRANSLATION_REMAP_SCHEMA, "passed": not errors, "errors": errors,
227
+ "translations": result, "applied": applied, "unmapped": sorted(unmapped),
228
+ "mappingCount": len(valid)}
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import hashlib
6
6
  import re
7
7
  from pathlib import Path
8
+ from typing import Any
8
9
 
9
10
 
10
11
  IMPORT_RE = re.compile(r"(?:import\s+(?:[^;\n]*?\s+from\s+)?|export\s+[^;\n]*?\s+from\s+)[\"']([^\"']+)[\"']")
@@ -49,3 +50,25 @@ def imported_components(route: Path) -> list[dict[str, str | None]]:
49
50
  "fingerprint": hashlib.sha256(resolved.read_bytes()).hexdigest() if resolved else None,
50
51
  })
51
52
  return result
53
+
54
+
55
+ def classify_bindings(bindings: list[dict[str, Any]], section_paths: set[str], page_paths: set[str]) -> dict[str, Any]:
56
+ """Classify component bindings against section and page route inventories.
57
+
58
+ Hosts supply the inventories from their renderer/database. This keeps the
59
+ report honest: a binding is not called "missing" merely because a legacy
60
+ component file still exists on disk.
61
+ """
62
+ records = []
63
+ for binding in bindings:
64
+ route = str(binding.get("route") or binding.get("path") or "")
65
+ component = str(binding.get("component") or "")
66
+ if route in section_paths or component in section_paths or binding.get("superseded") is True:
67
+ state = "superseded"
68
+ elif route in page_paths or binding.get("pageId"):
69
+ state = "migratable"
70
+ else:
71
+ state = "no-page-row"
72
+ records.append({**binding, "state": state})
73
+ counts = {state: sum(item["state"] == state for item in records) for state in ("superseded", "migratable", "no-page-row")}
74
+ return {"schemaVersion": "maggie-component-binding-audit.v1", "passed": not any(item["state"] == "no-page-row" for item in records), "counts": counts, "bindings": records}
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
  import json
5
5
  from datetime import datetime, timezone
6
6
  from pathlib import Path
7
+ from urllib.parse import urlparse
7
8
 
8
9
 
9
10
  def snapshot(report: dict, reviewer: str) -> dict:
@@ -16,15 +17,25 @@ def snapshot(report: dict, reviewer: str) -> dict:
16
17
  if not pages or len(pages) != crawl.get("discovered_url_count"):
17
18
  raise ValueError("baseline page coverage is incomplete")
18
19
  contracts = {}
20
+ content = {}
21
+ excluded_query_urls = []
19
22
  for page in pages:
20
23
  if not page.get("passed") or not isinstance(page.get("contract"), dict):
21
24
  raise ValueError("baseline requires successful page contracts")
22
25
  if page["url"] in contracts:
23
26
  raise ValueError("duplicate page URL")
27
+ if urlparse(page["url"]).query:
28
+ excluded_query_urls.append(page["url"])
29
+ continue
24
30
  contracts[page["url"]] = page["contract"]
31
+ if isinstance(page.get("contentContract"), dict):
32
+ content[page["url"]] = page["contentContract"]
33
+ if not contracts:
34
+ raise ValueError("baseline has no non-query page contracts")
25
35
  return {"schemaVersion": "maggie-site-baseline.v1", "url": report["url"],
26
36
  "reviewer": reviewer.strip(), "createdAt": datetime.now(timezone.utc).isoformat(),
27
- "pages": contracts}
37
+ "pages": contracts, "content": content,
38
+ "excludedQueryUrls": sorted(excluded_query_urls)}
28
39
 
29
40
 
30
41
  def compare(baseline: dict, report: dict) -> dict:
@@ -36,7 +47,10 @@ def compare(baseline: dict, report: dict) -> dict:
36
47
  errors.append("site origin/base URL differs from baseline")
37
48
  if not crawl.get("enabled") or not crawl.get("complete"):
38
49
  errors.append("complete sitemap crawl required")
39
- current = {page["url"]: page.get("contract") for page in crawl.get("pages", [])}
50
+ crawl_pages = crawl.get("pages", [])
51
+ query_urls = sorted(page["url"] for page in crawl_pages if urlparse(page["url"]).query)
52
+ current = {page["url"]: page.get("contract") for page in crawl_pages if not urlparse(page["url"]).query}
53
+ current_content = {page["url"]: page.get("contentContract") for page in crawl_pages if not urlparse(page["url"]).query and isinstance(page.get("contentContract"), dict)}
40
54
  expected = baseline["pages"]
41
55
  added, removed = sorted(current.keys() - expected.keys()), sorted(expected.keys() - current.keys())
42
56
  changes = []
@@ -48,8 +62,20 @@ def compare(baseline: dict, report: dict) -> dict:
48
62
  fields = sorted(key for key in expected[url].keys() | actual.keys() if expected[url].get(key) != actual.get(key))
49
63
  if fields:
50
64
  changes.append({"url": url, "fields": fields})
51
- return {"passed": not errors and not added and not removed and not changes,
52
- "errors": errors, "added": added, "removed": removed, "changed": changes}
65
+ expected_content = baseline.get("content", {})
66
+ content_changes = []
67
+ if expected_content:
68
+ for url in sorted(expected_content.keys() & current_content.keys()):
69
+ fields = sorted(key for key in expected_content[url].keys() | current_content[url].keys() if expected_content[url].get(key) != current_content[url].get(key))
70
+ if fields:
71
+ content_changes.append({"url": url, "fields": fields})
72
+ missing_content = sorted(expected_content.keys() - current_content.keys())
73
+ if missing_content:
74
+ errors.append("missing content contract: " + ", ".join(missing_content))
75
+ return {"passed": not errors and not added and not removed and not changes and not content_changes,
76
+ "errors": errors, "added": added, "removed": removed, "changed": changes,
77
+ "contentChanged": content_changes,
78
+ "excludedQueryUrls": query_urls}
53
79
 
54
80
 
55
81
  def save(path: Path, baseline: dict) -> None:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.9",
3
+ "version": "0.7.10",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",