@topy-ai/maggie 0.7.10 → 0.7.12

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.10 update --project . --force
222
- npx @topy-ai/maggie@0.7.10 cleanup --project .
221
+ npx @topy-ai/maggie@0.7.12 update --project . --force
222
+ npx @topy-ai/maggie@0.7.12 cleanup --project .
223
223
  ```
224
224
 
225
225
  Maintainers should pass npm credentials through the repository helper, never
@@ -229,7 +229,7 @@ as a command-line argument:
229
229
  node scripts/publish-npm.mjs --maggie-env-file ../.env
230
230
  ```
231
231
 
232
- The 0.7.10 workflow adds served-content equivalence checks, query-route
232
+ The 0.7.12 workflow adds served-content equivalence checks, query-route
233
233
  baseline exclusions, changed-surface render evidence gates, generated skill
234
234
  catalogs, component-binding audits, and icon-family noise filtering. It also
235
235
  includes the 0.7.9 nested section-field contracts, renderer-backed examples,
@@ -251,6 +251,13 @@ observations; every route must include status, canonical, indexability, and
251
251
  required metadata checks. Agent content writes require an explicit approved
252
252
  origin, same-origin redirects, and recursive credential validation. Feedback
253
253
  submissions persist and print only an allowlisted acknowledgement. The release
254
+ also adds image-aware content equivalence with explicit legacy-baseline
255
+ limitations, locale checks for stored image alt text, a validated label/value
256
+ `pairs` section, registry fan-out checks, idempotent reconciliation, per-step
257
+ and section-scoped design evidence, catch-all source confidence, shell-safe VPS
258
+ SSH guidance, compatible feedback CLI examples, conventional sitemap
259
+ formatting, response-header guidance, and access-log diagnostics for absent
260
+ crawler requests. The release
254
261
  retains the existing service matching,
255
262
  localization, seed-manifest, lockfile/analytics, sitemap, deployment and
256
263
  rollback workflows.
@@ -492,13 +499,32 @@ maggie design validate-ui --project . \
492
499
  This reuses the host project's homepage shell and `DESIGN.md`; it does not
493
500
  copy reference branding, source code, private data, or provider facts.
494
501
 
495
- The remaining installable skills are `maggie-blog-bootstrap`, `maggie-dash`,
496
- `maggie-clone`, `maggie-clone-to-template`, `maggie-marketplace`,
497
- `maggie-template`, `maggie-design`, `maggie-ops`, `maggie-deployment`,
498
- `maggie-project-context`, `maggie-social-share`, and `maggie-memory`.
502
+ For existing routes, in-place design jobs are resumable and can be narrowed to
503
+ one section. Catch-all routes remain `unproven` until their content source is
504
+ declared, and each completed step records evidence:
505
+
506
+ ```bash
507
+ maggie design in-place --project . --route /pricing \
508
+ --content-source src/lib/pages/db.ts
509
+ maggie design step in-place-<id> --project . \
510
+ --step inspect-route --evidence .maggie/evidence/route.json
511
+ maggie design section --project . --route /pricing --section-id hours
512
+ maggie design section-validate --before before.json --after after.json \
513
+ --section-id hours
514
+ ```
515
+
516
+ MaggieDash section contracts include a compact `pairs` band for factual
517
+ label/value rows. Validate required values, registry fan-out, locale sidecars,
518
+ and idempotent repairs with `maggie dash sections validate`,
519
+ `fanout-validate`, `locale-validate`, and `reconcile`.
499
520
 
500
- The package also includes `maggie-auth-reference` and `maggie-blog`. Use the
501
- stable commands below after installation:
521
+ The package includes all 18 installable skills: `maggie-blog-bootstrap`,
522
+ `maggie-dash`, `maggie-clone`, `maggie-clone-to-template`, `maggie-marketplace`,
523
+ `maggie-template`, `maggie-design`, `maggie-ops`, `maggie-deployment`,
524
+ `maggie-project-context`, `maggie-seo-geo`, `maggie-social-share`,
525
+ `maggie-service-booking`, `maggie-memory`, `maggie-content-localization`,
526
+ `maggie-feedback`, `maggie-auth-reference`, and `maggie-blog`. Use the stable
527
+ commands below after installation:
502
528
 
503
529
  ```bash
504
530
  maggie auth reference --project . --confirm
package/bin/maggie.js CHANGED
@@ -58,7 +58,7 @@ Usage:
58
58
  maggie doctor [--project PATH]
59
59
  maggie bootstrap interview [project]
60
60
  maggie dash init|install|status|migrate|transition|variant|cms --project PATH [options]
61
- maggie dash sections <validate|prompt|keys|remap-translations> [options]
61
+ maggie dash sections <validate|prompt|keys|remap-translations|fanout-validate|locale-validate|reconcile> [options]
62
62
  maggie dash components-audit --bindings-file FILE --sections-file FILE --pages-file FILE
63
63
  maggie dash status --project PATH
64
64
  maggie dash migrate --project PATH --confirm
@@ -85,6 +85,10 @@ Usage:
85
85
  maggie design validate-ui --project PATH --plan PATH --rendered-dir PATH --confirm
86
86
  maggie design icon-inventory --project PATH --source-dir src --runtime assets/icons.css --output docs/icon-inventory.json
87
87
  maggie design author --project PATH --route /about --purpose TEXT --audience TEXT --confirm
88
+ maggie design in-place --project PATH --route /pricing [--content-source PATH]
89
+ maggie design section --project PATH --route /pricing --section-id ID
90
+ maggie design section-validate --before FILE --after FILE --section-id ID
91
+ maggie design step <job-id> --step NAME --evidence FILE
88
92
  maggie auth reference --project PATH --confirm
89
93
  maggie auth check --project PATH [--production]
90
94
  maggie blog init|inspect|ingest|validate|publish|sitemap|settings|rollback|integration-state
@@ -108,7 +112,7 @@ Usage:
108
112
  maggie localization <extract|plan|generate|preview|validate|review|publish|stale|glossary> [options]
109
113
  maggie seo performance|images|sitemap [options] (sitemap supports strict validate and agent-files)
110
114
  maggie feedback <collect|preview|submit|list> [options]
111
- maggie site-audit URL [--crawl] [--languages en-GB,es-MX,ja-JP] [--check-hreflang]
115
+ maggie site-audit URL [--crawl] [--access-log FILE] [--require-sitemap-request] [--languages en-GB,es-MX,ja-JP] [--check-hreflang]
112
116
  maggie site-audit URL --crawl --save-baseline FILE --reviewer NAME
113
117
  maggie site-audit URL --crawl --baseline FILE
114
118
  maggie browser-audit URL --browse PATH --output DIR --required SELECTOR [--sticky SELECTOR]
@@ -181,6 +181,16 @@ maggie dash sections remap-translations \
181
181
  --translations-file .maggie/translations-legacy.json \
182
182
  --mapping-file .maggie/section-translation-map.json \
183
183
  --output .maggie/translations-v2.json --confirm
184
+
185
+ # Validate locale sidecars for stored image alt text before publishing:
186
+ maggie dash sections locale-validate \
187
+ --page-id <page-id> --sections-file .maggie/sections.json \
188
+ --translations-file .maggie/translations-by-locale.json --locale zh-Hant
189
+
190
+ # Check that a host wired every registry type through all implementation surfaces:
191
+ maggie dash sections fanout-validate \
192
+ --registry templates/maggiedash/section-registry.json \
193
+ --fanout-file templates/maggiedash/section-fanout.json
184
194
  ```
185
195
 
186
196
  The catalogue declares purpose, usage, placement, repeatability and layout
@@ -198,6 +208,15 @@ added band must have a real-renderer preview or an explicitly labelled example
198
208
  fallback at the supported breakpoints. Do not describe the starter vocabulary
199
209
  as a fixed number of bands.
200
210
 
211
+ Required fields and repeated `pairs` values cannot be blank. The `pairs`
212
+ starter band is intended for compact label/value facts such as opening hours;
213
+ it is not a prose fallback. The registry fan-out manifest is a host integration
214
+ contract: when a host adds a type, update its type union/schema, blank state,
215
+ validation, readable-content extraction, renderer, and editor entry together,
216
+ then run `fanout-validate`. `locale-validate` checks stored image-alt sidecars;
217
+ the host adapter must write the section and all non-default locale rows in one
218
+ transaction. The JSON report is evidence, not a database write.
219
+
201
220
  Translation keys use stable section IDs, with an array-index fallback only
202
221
  until the ordered migration is complete. Migration preserves unique IDs that
203
222
  already belong to the source section list; it generates a new ID only for a
@@ -37,6 +37,39 @@ systemd service, environment file, Nginx/TLS configuration, release root,
37
37
  database/media migration and backup strategy, health checks, and rollback
38
38
  owner before any remote mutation.
39
39
 
40
+ ### SSH and secret-source contract
41
+
42
+ Use these canonical project variables when a VPS adapter is configured:
43
+
44
+ | Variable | Meaning | Precedence |
45
+ | --- | --- | --- |
46
+ | `NOBLOX_DEPLOY_HOST` | VPS hostname or IP | explicit CLI flag, then project `.env`, then provider secret manager |
47
+ | `NOBLOX_DEPLOY_SSH_USER` | SSH login user | explicit CLI flag, then project `.env`, then provider secret manager |
48
+ | `NOBLOX_DEPLOY_SSH_PORT` | SSH port | explicit CLI flag, then project `.env`, then provider secret manager |
49
+ | `NOBLOX_DEPLOY_SSH_KEY` | path or secret reference for the private key | explicit CLI flag, then project `.env`, then provider secret manager |
50
+ | `NOBLOX_DEPLOY_ENV_FILE` | remote runtime env-file reference | provider secret manager only for the secret values |
51
+
52
+ Legacy provider-specific names may be supported by an adapter, but must be
53
+ mapped to these names in the local plan. The SSH key is preferred. Do not put
54
+ an SSH password in a plan or command line; if password authentication is
55
+ unavoidable, resolve `NOBLOX_DEPLOY_SSH_PASSWORD` at execution time from the
56
+ approved secret manager and never echo it. Explicit CLI values override `.env`;
57
+ `.env` overrides the provider adapter's default names. Values are validated by
58
+ name and presence only, never printed.
59
+
60
+ When composing SSH options in Bash or zsh, use an array so whitespace is not
61
+ reinterpreted as extra arguments:
62
+
63
+ ```zsh
64
+ ssh_opts=(-o StrictHostKeyChecking=accept-new -o BatchMode=yes)
65
+ ssh "${ssh_opts[@]}" -p "$NOBLOX_DEPLOY_SSH_PORT" \
66
+ "$NOBLOX_DEPLOY_SSH_USER@$NOBLOX_DEPLOY_HOST" "systemctl is-active example"
67
+ ```
68
+
69
+ Do not use `ssh_opts='-o StrictHostKeyChecking=accept-new -o BatchMode=yes'`
70
+ followed by `ssh $ssh_opts ...`; zsh does not perform the word splitting that
71
+ this pattern assumes.
72
+
40
73
  Create a local, secret-free plan before remote execution:
41
74
 
42
75
  ```bash
@@ -10,13 +10,33 @@ metadata:
10
10
  ## In-place route evidence
11
11
 
12
12
  `maggie design in-place --project . --route /example` resolves local source
13
- files and falls back to a discovered `[...slug]`/`[[...slug]]` file. That
14
- fallback is currently a source candidate, not proof that the concrete URL is
15
- served. Verify the route's HTTP response, content identity and framework route
13
+ files and falls back to a discovered `[...slug]`/`[[...slug]]` file. A catch-all
14
+ is recorded as `resolution: unproven` unless `--content-source` names the
15
+ actual DB/content resolver. A source candidate is not proof that the concrete
16
+ URL is served: verify the HTTP response, content identity, and framework route
16
17
  mapping before editing a shared catch-all. Next app-router catch-alls whose
17
- file is named `page.tsx` are not covered by this filename fallback. Capture
18
- responsive and interaction evidence separately; a plan in phase `ready` is
19
- not a successful browser test.
18
+ file is named `page.tsx` are not covered by this filename fallback.
19
+
20
+ In-place work is resumable. Record each completed step and its evidence:
21
+
22
+ ```bash
23
+ maggie design step in-place-<id> --project . \
24
+ --step inspect-route --evidence .maggie/evidence/route.json
25
+ maggie design status in-place-<id> --project .
26
+ ```
27
+
28
+ The job becomes `done` only after every required step has evidence; `ready`
29
+ means the contract exists, not that browser QA passed. For a one-band change,
30
+ use section-scoped mode and validate before/after evidence:
31
+
32
+ ```bash
33
+ maggie design section --project . --route /pricing --section-id hours
34
+ maggie design section-validate --before before.json --after after.json \
35
+ --section-id hours
36
+ ```
37
+
38
+ The validator requires the target section to change and proves that every
39
+ other section remains unchanged.
20
40
 
21
41
  ## Source-to-runtime icon inventory
22
42
 
@@ -16,7 +16,7 @@ general lesson or regression test.
16
16
  ## Collect locally
17
17
 
18
18
  ```bash
19
- maggie feedback collect --project . \
19
+ maggie feedback --project . collect \
20
20
  --skill maggie-clone-to-template --run-id clone-001 \
21
21
  --type bug --phase visual-qa \
22
22
  --summary "Mobile hero overflows the viewport" \
@@ -26,6 +26,7 @@ maggie feedback collect --project . \
26
26
  --reproduce "Run the clone workflow" \
27
27
  --reproduce "Open the mobile screenshot" \
28
28
  --screenshot ./screenshots/mobile.png
29
+ # The equivalent `maggie feedback collect --project . ...` spelling is also supported.
29
30
  maggie feedback preview .maggie/feedback/<feedback-id>.json --format markdown
30
31
  ```
31
32
 
@@ -59,6 +59,26 @@ If the change date is unknown, omit `lastmod`. An empty content-type does not
59
59
  need a sitemap chunk in the sitemap index; serving an empty endpoint and
60
60
  advertising it are separate decisions.
61
61
 
62
+ Generated sitemap XML uses the conventional readable shape by default: one
63
+ `<url>`/`<sitemap>` entry per block, UTF-8 XML, and date-only `lastmod` evidence
64
+ rendered as a full UTC W3C datetime. The plan also exposes the response
65
+ contract (`text/xml; charset=utf-8` and `X-Robots-Tag: all`) for the framework
66
+ route or reverse proxy to apply; writing a file cannot set HTTP headers itself.
67
+ When Search Console reports a valid sitemap as unfetched, probe success alone
68
+ is not a diagnosis. Supply a sanitized local access log to distinguish
69
+ `googlebot-request-observed`, another-client-only traffic, and
70
+ `no-matching-request`:
71
+
72
+ ```bash
73
+ maggie site-audit https://example.com --access-log /var/log/nginx/access.log
74
+ maggie site-audit https://example.com --access-log /var/log/nginx/access.log \
75
+ --require-sitemap-request
76
+ ```
77
+
78
+ The strict flag is for a review window where a crawler request is expected; it
79
+ fails only when the supplied log contains no sitemap request. Never upload raw
80
+ logs or include IPs, credentials, or query data in feedback or reports.
81
+
62
82
  ## Freeze and compare a reviewed site
63
83
 
64
84
  ```bash
@@ -0,0 +1,11 @@
1
+ {
2
+ "schemaVersion": "maggiedash-section-fanout.v1",
3
+ "description": "Starter host contract: every registry type must be wired through each implementation surface. Hosts extending the registry must regenerate this file.",
4
+ "typeUnion": ["cards", "cta", "faq", "hero", "links", "pairs", "prose"],
5
+ "sectionSchema": ["cards", "cta", "faq", "hero", "links", "pairs", "prose"],
6
+ "blank": ["cards", "cta", "faq", "hero", "links", "pairs", "prose"],
7
+ "validation": ["cards", "cta", "faq", "hero", "links", "pairs", "prose"],
8
+ "textExtraction": ["cards", "cta", "faq", "hero", "links", "pairs", "prose"],
9
+ "renderer": ["cards", "cta", "faq", "hero", "links", "pairs", "prose"],
10
+ "editor": ["cards", "cta", "faq", "hero", "links", "pairs", "prose"]
11
+ }
@@ -33,6 +33,19 @@
33
33
  ],
34
34
  "example": {"type": "prose", "heading": "Make the important part easier to understand", "paragraphs": ["Start with the decision your reader is trying to make.", "Then give them the context, evidence and next step in that order."]}
35
35
  },
36
+ {
37
+ "type": "pairs",
38
+ "purpose": "Shows named facts as a compact label-and-value band.",
39
+ "usage": "Use for opening hours, specifications, eligibility, or other factual pairs; never for long prose.",
40
+ "position": "body",
41
+ "repeatable": true,
42
+ "fields": [
43
+ {"name": "heading", "usage": "What the facts describe.", "limit": 90, "required": true},
44
+ {"name": "rows", "usage": "Non-empty factual label/value pairs.", "limit": 0, "repeats": {"min": 1, "max": 12, "of": [{"name": "label", "usage": "The fact name.", "limit": 70, "required": true}, {"name": "value", "usage": "The concise fact value; never blank.", "limit": 180, "required": true}]}},
45
+ {"name": "note", "usage": "Optional clarification below the facts.", "limit": 220}
46
+ ],
47
+ "example": {"type": "pairs", "heading": "At a glance", "rows": [{"label": "Typical response", "value": "Within one working day"}, {"label": "Available", "value": "Monday to Friday"}], "note": "Times may change on public holidays."}
48
+ },
36
49
  {
37
50
  "type": "cards",
38
51
  "purpose": "Presents parallel points side by side.",
@@ -23,7 +23,7 @@ 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, remap_translations, section_id_migration, validate_registry, copy_notes # noqa: E402
26
+ from maggie_sections import catalogue, remap_translations, section_id_migration, validate_registry, copy_notes, validate_values, validate_fanout, reconcile_fields, validate_locale_coverage # noqa: E402
27
27
  from route_imports import classify_bindings # noqa: E402
28
28
 
29
29
 
@@ -334,6 +334,33 @@ def command_sections(args: argparse.Namespace) -> int:
334
334
  result["output"] = str(output)
335
335
  emit(result)
336
336
  return 0 if result["passed"] else 1
337
+ if args.sections_command == "fanout-validate":
338
+ registry = json.loads(Path(args.registry).resolve().read_text(encoding="utf-8"))
339
+ fanout = json.loads(Path(args.fanout_file).resolve().read_text(encoding="utf-8"))
340
+ checked = validate_registry(registry)
341
+ result = {**validate_fanout(registry, fanout), "registry": checked}
342
+ emit(result)
343
+ return 0 if result["passed"] and checked["passed"] else 1
344
+ if args.sections_command == "reconcile":
345
+ current = json.loads(Path(args.current_file).resolve().read_text(encoding="utf-8"))
346
+ desired = json.loads(Path(args.desired_file).resolve().read_text(encoding="utf-8"))
347
+ result = reconcile_fields(current, desired)
348
+ if result["passed"]:
349
+ require_confirm(args)
350
+ output = Path(args.output).resolve()
351
+ if output.exists() and not args.force:
352
+ raise ValueError(f"output exists; use --force to replace: {output}")
353
+ output.parent.mkdir(parents=True, exist_ok=True)
354
+ output.write_text(json.dumps(result["result"], indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
355
+ result["output"] = str(output)
356
+ emit(result)
357
+ return 0 if result["passed"] else 1
358
+ if args.sections_command == "locale-validate":
359
+ sections = json.loads(Path(args.sections_file).resolve().read_text(encoding="utf-8"))
360
+ translations = json.loads(Path(args.translations_file).resolve().read_text(encoding="utf-8"))
361
+ result = validate_locale_coverage(args.page_id, sections, translations, args.locale)
362
+ emit(result)
363
+ return 0 if result["passed"] else 1
337
364
  registry = json.loads(Path(args.registry).resolve().read_text(encoding="utf-8"))
338
365
  checked = validate_registry(registry)
339
366
  if not checked["passed"]:
@@ -344,9 +371,9 @@ def command_sections(args: argparse.Namespace) -> int:
344
371
  return 0
345
372
  if args.sections_command == "validate":
346
373
  sections = json.loads(Path(args.sections_file).resolve().read_text(encoding="utf-8"))
347
- result = {**checked, "copy": copy_notes(registry, sections)}
374
+ result = {**checked, "copy": copy_notes(registry, sections), "values": validate_values(registry, sections)}
348
375
  emit(result)
349
- return 0 if result["copy"]["passed"] else 1
376
+ return 0 if result["copy"]["passed"] and result["values"]["passed"] else 1
350
377
  sections = json.loads(Path(args.sections_file).resolve().read_text(encoding="utf-8"))
351
378
  emit(section_id_migration(args.page_id, sections))
352
379
  return 0
@@ -484,6 +511,15 @@ def parser() -> argparse.ArgumentParser:
484
511
  sections_remap = sections_sub.add_parser("remap-translations", help="move an existing translation keyspace into stable section keys")
485
512
  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
513
  sections_remap.set_defaults(func=command_sections)
514
+ sections_fanout = sections_sub.add_parser("fanout-validate", help="check registry type fan-out across host implementation surfaces")
515
+ sections_fanout.add_argument("--registry", required=True); sections_fanout.add_argument("--fanout-file", required=True)
516
+ sections_fanout.set_defaults(func=command_sections)
517
+ sections_reconcile = sections_sub.add_parser("reconcile", help="apply an idempotent compare-and-set JSON repair")
518
+ sections_reconcile.add_argument("--current-file", required=True); sections_reconcile.add_argument("--desired-file", required=True); sections_reconcile.add_argument("--output", required=True); sections_reconcile.add_argument("--force", action="store_true"); sections_reconcile.add_argument("--confirm", action="store_true")
519
+ sections_reconcile.set_defaults(func=command_sections)
520
+ sections_locale = sections_sub.add_parser("locale-validate", help="check locale sidecar coverage for stored image alt fields")
521
+ sections_locale.add_argument("--page-id", required=True); sections_locale.add_argument("--sections-file", required=True); sections_locale.add_argument("--translations-file", required=True); sections_locale.add_argument("--locale", action="append", required=True)
522
+ sections_locale.set_defaults(func=command_sections)
487
523
  sections_keys.set_defaults(func=command_sections)
488
524
  components = sub.add_parser("components-audit", help="classify route component bindings against live section/page inventories")
489
525
  components.add_argument("--bindings-file", required=True)
@@ -328,7 +328,7 @@ PHASES = [
328
328
  ],
329
329
  },
330
330
  ]
331
- DESIGN_JOB_PHASES = ("created", "preflight", "planned", "validated", "ready", "failed")
331
+ DESIGN_JOB_PHASES = ("created", "preflight", "planned", "validated", "ready", "done", "failed")
332
332
 
333
333
 
334
334
  def existing_evidence(project: Path, target: dict[str, str]) -> list[str]:
@@ -506,7 +506,32 @@ def design_resume(project: Path, job_id: str) -> int:
506
506
  return run_design_job(job["urls"], project, job["clone_run"], force=True)[0]
507
507
 
508
508
 
509
- def in_place_job(project: Path, routes: list[str], force: bool = False) -> int:
509
+ def _step_records(names: list[str]) -> list[dict[str, object]]:
510
+ return [{"name": name, "status": "pending", "evidence": None} for name in names]
511
+
512
+
513
+ def design_step(project: Path, job_id: str, step: str, evidence: str) -> int:
514
+ """Record one piece of implementation evidence and make progress durable."""
515
+ path = design_job_path(project.resolve(), job_id)
516
+ if not path.exists():
517
+ raise ValueError(f"design job not found: {path}")
518
+ job = json.loads(path.read_text(encoding="utf-8"))
519
+ steps = job.get("steps", [])
520
+ target = next((item for item in steps if item.get("name") == step), None)
521
+ if target is None:
522
+ raise ValueError(f"unknown design step for {job_id}: {step}")
523
+ evidence_path = Path(evidence).expanduser().resolve()
524
+ if not evidence_path.exists():
525
+ raise ValueError(f"step evidence does not exist: {evidence_path}")
526
+ target.update({"status": "done", "evidence": str(evidence_path), "completedAt": datetime.now(timezone.utc).isoformat()})
527
+ job.setdefault("history", []).append({"event": "step-completed", "step": step, "evidence": str(evidence_path), "at": datetime.now(timezone.utc).isoformat()})
528
+ job["phase"] = "done" if all(item.get("status") == "done" for item in steps) else "ready"
529
+ save_design_job(project, job)
530
+ print(json.dumps({"jobId": job_id, "step": step, "status": target["status"], "phase": job["phase"], "evidence": str(evidence_path)}, indent=2))
531
+ return 0
532
+
533
+
534
+ def in_place_job(project: Path, routes: list[str], force: bool = False, content_source: str = "") -> int:
510
535
  """Create a resumable redesign contract for existing first-party routes."""
511
536
  project = project.resolve()
512
537
  if not routes:
@@ -540,7 +565,13 @@ def in_place_job(project: Path, routes: list[str], force: bool = False) -> int:
540
565
  break
541
566
  if not match:
542
567
  raise ValueError(f"existing route required for in-place redesign: {route}")
543
- resolved_routes.append({"route": value, "source": str(match)})
568
+ catch_all = match.stem in {"[...slug]", "[[...slug]]"}
569
+ resolved_routes.append({
570
+ "route": value,
571
+ "source": str(match),
572
+ "resolution": "declared" if content_source else ("unproven" if catch_all else "direct-file"),
573
+ "contentSource": content_source or ("unproven catch-all route; declare the DB/content resolver before implementation" if catch_all else str(match)),
574
+ })
544
575
  digest = hashlib.sha256((str(project) + "\n" + "\n".join(routes)).encode()).hexdigest()[:12]
545
576
  job_id = f"in-place-{digest}"
546
577
  path = design_job_path(project, job_id)
@@ -558,7 +589,34 @@ def in_place_job(project: Path, routes: list[str], force: bool = False) -> int:
558
589
  "clone_run_required": False,
559
590
  "shared_tokens_required": True,
560
591
  "palette_change_allowed": False,
561
- "required_steps": ["inspect-route", "apply-design-contract", "visual-review", "route-validation"],
592
+ "required_steps": ["inspect-route", "declare-content-source", "apply-design-contract", "visual-review", "route-validation"],
593
+ "steps": _step_records(["inspect-route", "declare-content-source", "apply-design-contract", "visual-review", "route-validation"]),
594
+ "history": [{"phase": "ready", "at": datetime.now(timezone.utc).isoformat()}],
595
+ }
596
+ save_design_job(project, job)
597
+ print(json.dumps(job, indent=2, ensure_ascii=False))
598
+ return 0
599
+
600
+
601
+ def section_job(project: Path, route: str, section_id: str, force: bool = False) -> int:
602
+ """Create a focused design job with an unchanged-rest assertion."""
603
+ project = project.resolve()
604
+ if not route.startswith("/") or not section_id.strip():
605
+ raise ValueError("section mode requires a route starting with / and a section id")
606
+ contract = require_design_contract(project)
607
+ digest = hashlib.sha256((str(project) + "\n" + route + "\n" + section_id).encode()).hexdigest()[:12]
608
+ job_id = f"section-{digest}"
609
+ path = design_job_path(project, job_id)
610
+ if path.exists() and not force:
611
+ print(path.read_text(encoding="utf-8"), end="")
612
+ return 0
613
+ required = ["inspect-section", "capture-before", "implement-section", "capture-after", "assert-unchanged-rest", "route-validation"]
614
+ job = {
615
+ "id": job_id, "workflow": "maggie-design", "mode": "section-scoped", "phase": "ready",
616
+ "project": str(project), "route": route, "sectionId": section_id,
617
+ "design_contract": contract, "clone_run_required": False,
618
+ "scope": {"targetSection": section_id, "unchangedRestAssertion": "all non-target section content and structure hashes remain unchanged"},
619
+ "required_steps": required, "steps": _step_records(required),
562
620
  "history": [{"phase": "ready", "at": datetime.now(timezone.utc).isoformat()}],
563
621
  }
564
622
  save_design_job(project, job)
@@ -566,6 +624,25 @@ def in_place_job(project: Path, routes: list[str], force: bool = False) -> int:
566
624
  return 0
567
625
 
568
626
 
627
+ def validate_section_evidence(before_path: Path, after_path: Path, section_id: str) -> dict[str, object]:
628
+ """Compare section-scoped evidence and prove the rest stayed unchanged."""
629
+ before = json.loads(before_path.resolve().read_text(encoding="utf-8"))
630
+ after = json.loads(after_path.resolve().read_text(encoding="utf-8"))
631
+ before_sections = {str(item.get("id")): item for item in before.get("sections", []) if isinstance(item, dict) and item.get("id")}
632
+ after_sections = {str(item.get("id")): item for item in after.get("sections", []) if isinstance(item, dict) and item.get("id")}
633
+ errors = []
634
+ if section_id not in before_sections or section_id not in after_sections:
635
+ errors.append("target section is missing from before or after evidence")
636
+ unchanged = sorted(section for section in before_sections.keys() & after_sections.keys() if section != section_id and before_sections[section] != after_sections[section])
637
+ if unchanged:
638
+ errors.append("non-target sections changed: " + ", ".join(unchanged))
639
+ missing_rest = sorted((before_sections.keys() ^ after_sections.keys()) - {section_id})
640
+ if missing_rest:
641
+ errors.append("non-target section set changed: " + ", ".join(missing_rest))
642
+ target_changed = section_id in before_sections and section_id in after_sections and before_sections[section_id] != after_sections[section_id]
643
+ return {"schemaVersion": "maggie-section-evidence.v1", "passed": not errors and target_changed, "targetSection": section_id, "targetChanged": target_changed, "unchangedRest": not unchanged and not missing_rest, "changedNonTargetSections": unchanged, "errors": errors + ([] if target_changed else ["target section did not change"]), "before": str(before_path.resolve()), "after": str(after_path.resolve())}
644
+
645
+
569
646
  def author_job(project: Path, route: str, purpose: str, audience: str, brief_file: Path | None, confirm: bool) -> int:
570
647
  """Create an original page brief without requiring an external source URL."""
571
648
  project = project.resolve()
@@ -592,6 +669,20 @@ def author_job(project: Path, route: str, purpose: str, audience: str, brief_fil
592
669
 
593
670
 
594
671
  def main() -> int:
672
+ if len(sys.argv) > 1 and sys.argv[1] in {"-h", "--help"}:
673
+ print("""usage: maggie_design.py <command> [options]
674
+
675
+ commands:
676
+ run run the complete clone-backed design workflow
677
+ in-place create a resumable redesign contract for existing routes
678
+ section create a section-scoped redesign contract
679
+ step record completion evidence for one design step
680
+ status show a design job and its per-step progress
681
+ resume restart a failed design workflow
682
+ author plan an original first-party page
683
+ reference-ui, init, validate-ui, rebrand, review
684
+ """)
685
+ return 0
595
686
  if len(sys.argv) > 1 and sys.argv[1] == "reference-ui":
596
687
  command = argparse.ArgumentParser(description="Record sanitized blog/service UI evidence from a read-only reference project.")
597
688
  command.add_argument("--project", type=Path, default=Path.cwd())
@@ -653,13 +744,51 @@ def main() -> int:
653
744
  in_place = argparse.ArgumentParser(description="Create an in-place redesign contract for existing first-party routes.")
654
745
  in_place.add_argument("--project", type=Path, default=Path.cwd())
655
746
  in_place.add_argument("--route", action="append", required=True, help="existing route, for example /pricing")
747
+ in_place.add_argument("--content-source", default="", help="declared DB/content resolver or source manifest for catch-all routes")
656
748
  in_place.add_argument("--force", action="store_true")
657
749
  args = in_place.parse_args(sys.argv[2:])
658
750
  try:
659
- return in_place_job(args.project, args.route, args.force)
751
+ return in_place_job(args.project, args.route, args.force, args.content_source)
660
752
  except (OSError, ValueError, json.JSONDecodeError) as error:
661
753
  print(f"BLOCKED: maggie-design in-place: {error}", file=sys.stderr)
662
754
  return 1
755
+ if len(sys.argv) > 1 and sys.argv[1] == "section":
756
+ section = argparse.ArgumentParser(description="Create a section-scoped redesign contract with an unchanged-rest assertion.")
757
+ section.add_argument("--project", type=Path, default=Path.cwd())
758
+ section.add_argument("--route", required=True)
759
+ section.add_argument("--section-id", required=True)
760
+ section.add_argument("--force", action="store_true")
761
+ args = section.parse_args(sys.argv[2:])
762
+ try:
763
+ return section_job(args.project, args.route, args.section_id, args.force)
764
+ except (OSError, ValueError, json.JSONDecodeError) as error:
765
+ print(f"BLOCKED: maggie-design section: {error}", file=sys.stderr)
766
+ return 1
767
+ if len(sys.argv) > 1 and sys.argv[1] == "step":
768
+ step = argparse.ArgumentParser(description="Record evidence for one design workflow step.")
769
+ step.add_argument("job_id")
770
+ step.add_argument("--project", type=Path, default=Path.cwd())
771
+ step.add_argument("--step", required=True)
772
+ step.add_argument("--evidence", type=Path, required=True)
773
+ args = step.parse_args(sys.argv[2:])
774
+ try:
775
+ return design_step(args.project, args.job_id, args.step, str(args.evidence))
776
+ except (OSError, ValueError, json.JSONDecodeError) as error:
777
+ print(f"BLOCKED: maggie-design step: {error}", file=sys.stderr)
778
+ return 1
779
+ if len(sys.argv) > 1 and sys.argv[1] == "section-validate":
780
+ section_validate = argparse.ArgumentParser(description="Validate before/after evidence for one section and assert the rest is unchanged.")
781
+ section_validate.add_argument("--before", type=Path, required=True)
782
+ section_validate.add_argument("--after", type=Path, required=True)
783
+ section_validate.add_argument("--section-id", required=True)
784
+ args = section_validate.parse_args(sys.argv[2:])
785
+ try:
786
+ result = validate_section_evidence(args.before, args.after, args.section_id)
787
+ print(json.dumps(result, indent=2, ensure_ascii=False))
788
+ return 0 if result["passed"] else 1
789
+ except (OSError, ValueError, json.JSONDecodeError) as error:
790
+ print(f"BLOCKED: maggie-design section-validate: {error}", file=sys.stderr)
791
+ return 1
663
792
  if len(sys.argv) > 1 and sys.argv[1] == "author":
664
793
  author = argparse.ArgumentParser(description="Create an original first-party page brief and route plan.")
665
794
  author.add_argument("--project", type=Path, default=Path.cwd())
@@ -204,6 +204,9 @@ def main() -> int:
204
204
  parser.add_argument("--project", default=".")
205
205
  sub = parser.add_subparsers(dest="command", required=True)
206
206
  collect_parser = sub.add_parser("collect")
207
+ # Accept the project flag after the subcommand as shown in the public
208
+ # examples. argparse otherwise only accepts the global flag before it.
209
+ collect_parser.add_argument("--project", default=argparse.SUPPRESS)
207
210
  collect_parser.add_argument("--skill", default="")
208
211
  collect_parser.add_argument("--run-id", default="")
209
212
  collect_parser.add_argument("--run-report")
@@ -168,7 +168,17 @@ def audit_page(url: str, html: str, status: int, content_type: str, expected_lan
168
168
  "headings": page.headings,
169
169
  "paragraphs": page.paragraphs,
170
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(),
171
+ # Images and their alt text are readable content too. Keeping them
172
+ # in this contract prevents a prose-only migration from silently
173
+ # dropping the visual content of a page.
174
+ "images": [{"src": urljoin(url, image["src"]), "alt": image.get("alt")} for image in page.images],
175
+ "imageAlts": [image.get("alt") for image in page.images],
176
+ "contentHash": hashlib.sha256(json.dumps({
177
+ "headings": page.headings,
178
+ "paragraphs": page.paragraphs,
179
+ "links": [{"href": urljoin(url, link["href"]), "text": link["text"]} for link in page.links],
180
+ "images": [{"src": urljoin(url, image["src"]), "alt": image.get("alt")} for image in page.images],
181
+ }, sort_keys=True, ensure_ascii=False).encode()).hexdigest(),
172
182
  },
173
183
  "robots": not robots or not any(token in {"noindex", "none", "nofollow"} for token in robots_tokens),
174
184
  "robots_directives": robots,
@@ -247,6 +257,15 @@ def sitemap_urls(base: str) -> tuple[list[str], list[dict[str, str]]]:
247
257
  return list(dict.fromkeys(pages)), violations
248
258
 
249
259
 
260
+ def access_log_sitemap_check(path: Path, sitemap_path: str = "/sitemap.xml") -> dict[str, object]:
261
+ """Distinguish a sitemap fetch failure from a crawler that never asked."""
262
+ text = path.read_text(encoding="utf-8", errors="replace")
263
+ escaped = re.escape(sitemap_path)
264
+ requests = [line for line in text.splitlines() if re.search(rf"(?:GET|HEAD)\s+{escaped}(?:[?\s\"]|$)", line, re.I)]
265
+ googlebot = [line for line in requests if "googlebot" in line.lower()]
266
+ return {"provided": True, "path": str(path), "requestCount": len(requests), "googlebotRequestCount": len(googlebot), "status": "requested" if requests else "not-requested", "diagnosis": "googlebot-request-observed" if googlebot else ("other-client-request-only" if requests else "no-matching-request")}
267
+
268
+
250
269
  def main() -> int:
251
270
  parser = argparse.ArgumentParser()
252
271
  parser.add_argument("url")
@@ -259,6 +278,8 @@ def main() -> int:
259
278
  parser.add_argument("--markets", help="comma-separated markets: global,uk,us")
260
279
  parser.add_argument("--check-hreflang", action="store_true")
261
280
  parser.add_argument("--check-translation-completeness", action="store_true")
281
+ parser.add_argument("--access-log", type=Path, help="optional local access log for crawler-request diagnosis")
282
+ parser.add_argument("--require-sitemap-request", action="store_true", help="fail unless the supplied access log contains a sitemap request")
262
283
  baseline_args = parser.add_mutually_exclusive_group()
263
284
  baseline_args.add_argument("--save-baseline", type=Path, help="create a new reviewed contract from a passing complete crawl")
264
285
  baseline_args.add_argument("--baseline", type=Path, help="fail on differences from a reviewed contract")
@@ -316,6 +337,12 @@ def main() -> int:
316
337
  "mentions_sitemap": "sitemap" in body.lower() if name == "robots" else None,
317
338
  "url_count": len(re.findall(r"<loc>.*?</loc>", body, re.I | re.S)) if name == "sitemap" else None,
318
339
  }
340
+ if name == "sitemap" and args.access_log:
341
+ access = access_log_sitemap_check(args.access_log)
342
+ checks["sitemap_access_log"] = {"ok": not args.require_sitemap_request or access["requestCount"] > 0, **access}
343
+ checks[name]["access_log"] = access
344
+ if args.require_sitemap_request and not access["requestCount"]:
345
+ checks[name]["ok"] = False
319
346
  except Exception as exc:
320
347
  checks[name] = {"ok": False, "error": type(exc).__name__}
321
348
 
@@ -13,6 +13,9 @@ from typing import Any, Iterable
13
13
  SCHEMA = "maggiedash-section-registry.v1"
14
14
  IDENTITY_SCHEMA = "maggiedash-section-identity.v1"
15
15
  TRANSLATION_REMAP_SCHEMA = "maggiedash-translation-remap.v1"
16
+ FANOUT_SCHEMA = "maggiedash-section-fanout.v1"
17
+ RECONCILE_SCHEMA = "maggiedash-reconcile.v1"
18
+ LOCALE_SCHEMA = "maggiedash-locale-coverage.v1"
16
19
 
17
20
 
18
21
  def _validate_field(field: object, at: str, errors: list[str], *, nested: bool = False) -> None:
@@ -154,6 +157,72 @@ def copy_notes(registry: dict[str, Any], sections: object) -> dict[str, Any]:
154
157
  return {"passed": not errors, "errors": errors, "notes": notes}
155
158
 
156
159
 
160
+ def validate_values(registry: dict[str, Any], sections: object) -> dict[str, Any]:
161
+ """Validate required scalar and repeated values, including pairs rows."""
162
+ if not isinstance(sections, list):
163
+ return {"passed": False, "errors": ["sections must be a list"]}
164
+ specs = {str(item.get("type")): item for item in registry.get("sections", []) if isinstance(item, dict)}
165
+ errors: list[str] = []
166
+
167
+ def check_fields(value: object, fields: list[dict[str, Any]], path: str) -> None:
168
+ for field in fields:
169
+ name = str(field.get("name"))
170
+ child = value.get(name) if isinstance(value, dict) else None
171
+ at = f"{path}.{name}"
172
+ if field.get("required") and (child is None or (isinstance(child, str) and not child.strip())):
173
+ errors.append(f"{at} is required and cannot be blank")
174
+ repeats = field.get("repeats")
175
+ if not isinstance(repeats, dict) or not isinstance(child, list):
176
+ continue
177
+ if not repeats["min"] <= len(child) <= repeats["max"]:
178
+ errors.append(f"{at} must contain {repeats['min']}-{repeats['max']} entries")
179
+ child_fields = [item for item in repeats.get("of", []) if isinstance(item, dict)]
180
+ for index, item in enumerate(child):
181
+ if isinstance(item, dict):
182
+ check_fields(item, child_fields, f"{at}[{index}]")
183
+ elif len(child_fields) == 1 and child_fields[0].get("required") and not str(item).strip():
184
+ errors.append(f"{at}[{index}] is required and cannot be blank")
185
+
186
+ for index, section in enumerate(sections):
187
+ if not isinstance(section, dict):
188
+ errors.append(f"sections[{index}] must be an object")
189
+ continue
190
+ spec = specs.get(str(section.get("type")))
191
+ if spec:
192
+ check_fields(section, [field for field in spec.get("fields", []) if isinstance(field, dict)], f"sections[{index}]")
193
+ return {"passed": not errors, "errors": errors}
194
+
195
+
196
+ def translation_key_paths(page_id: str, sections: object, field_names: tuple[str, ...] = ("imageAlt",)) -> list[str]:
197
+ """Find stored section fields that need a locale sidecar value."""
198
+ if not isinstance(sections, list):
199
+ return []
200
+ paths: list[str] = []
201
+
202
+ def walk(value: object, path: str) -> None:
203
+ if isinstance(value, dict):
204
+ for key, child in value.items():
205
+ child_path = f"{path}.{key}"
206
+ if key in field_names and isinstance(child, str) and child.strip():
207
+ paths.append(child_path)
208
+ walk(child, child_path)
209
+ elif isinstance(value, list):
210
+ for index, child in enumerate(value):
211
+ walk(child, f"{path}.{index}")
212
+
213
+ for index, section in enumerate(sections):
214
+ walk(section, f"page.{page_id}.sections.{section_key(section, index)}")
215
+ return paths
216
+
217
+
218
+ def validate_locale_coverage(page_id: str, sections: object, translations: object, locales: Iterable[str]) -> dict[str, Any]:
219
+ """Require non-default locale rows for every declared translatable field."""
220
+ paths = translation_key_paths(page_id, sections)
221
+ values = translations if isinstance(translations, dict) else {}
222
+ missing = [{"locale": locale, "key": key} for locale in locales for key in paths if not isinstance(values.get(locale), dict) or not str(values[locale].get(key) or "").strip()]
223
+ return {"schemaVersion": LOCALE_SCHEMA, "passed": not missing, "pageId": page_id, "requiredKeys": paths, "missing": missing, "writePolicy": "section row and locale rows must be committed in one transaction"}
224
+
225
+
157
226
  def new_section_id(existing: Iterable[str] = ()) -> str:
158
227
  taken = set(existing)
159
228
  while True:
@@ -226,3 +295,39 @@ def remap_translations(translations: object, mappings: object) -> dict[str, Any]
226
295
  return {"schemaVersion": TRANSLATION_REMAP_SCHEMA, "passed": not errors, "errors": errors,
227
296
  "translations": result, "applied": applied, "unmapped": sorted(unmapped),
228
297
  "mappingCount": len(valid)}
298
+
299
+
300
+ def validate_fanout(registry: dict[str, Any], fanout: object) -> dict[str, Any]:
301
+ """Ensure every registry type is wired through each host implementation surface.
302
+
303
+ A registry is declarative, but hosts still need a type union/schema,
304
+ blank-state logic, validation, text extraction, renderer, and editor
305
+ affordance. This contract makes a missing fan-out edit fail loudly.
306
+ """
307
+ if not isinstance(fanout, dict) or fanout.get("schemaVersion") != FANOUT_SCHEMA:
308
+ return {"schemaVersion": FANOUT_SCHEMA, "passed": False, "errors": [f"fanout schemaVersion must be {FANOUT_SCHEMA}"]}
309
+ types = {str(item.get("type")) for item in registry.get("sections", []) if isinstance(item, dict)}
310
+ surfaces = ("typeUnion", "sectionSchema", "blank", "validation", "textExtraction", "renderer", "editor")
311
+ missing: list[dict[str, str]] = []
312
+ for surface in surfaces:
313
+ values = fanout.get(surface)
314
+ declared = set(values) if isinstance(values, list) else set(values.keys()) if isinstance(values, dict) else set()
315
+ for section_type in sorted(types - declared):
316
+ missing.append({"type": section_type, "surface": surface})
317
+ return {"schemaVersion": FANOUT_SCHEMA, "passed": not missing, "errors": [f"{item['type']} missing {item['surface']} fan-out" for item in missing], "sectionTypes": sorted(types), "missing": missing}
318
+
319
+
320
+ def reconcile_fields(current: object, desired: object) -> dict[str, Any]:
321
+ """Return an idempotent compare-and-set result for repair scripts."""
322
+ if not isinstance(current, dict) or not isinstance(desired, dict):
323
+ return {"schemaVersion": RECONCILE_SCHEMA, "passed": False, "errors": ["current and desired must be objects"]}
324
+ result = copy.deepcopy(current)
325
+ changed: list[str] = []
326
+ already_correct: list[str] = []
327
+ for key, value in desired.items():
328
+ if current.get(key) == value:
329
+ already_correct.append(str(key))
330
+ else:
331
+ result[key] = copy.deepcopy(value)
332
+ changed.append(str(key))
333
+ return {"schemaVersion": RECONCILE_SCHEMA, "passed": True, "changed": changed, "alreadyCorrect": already_correct, "result": result, "convergent": True}
@@ -74,13 +74,29 @@ def filter_routes(routes: list[dict[str, str]], origin: str, content_types: set[
74
74
  return groups, excluded
75
75
 
76
76
 
77
+ def conventional_lastmod(value: str) -> str:
78
+ """Render date-only source evidence as a W3C/ISO UTC datetime."""
79
+ if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
80
+ return value + "T00:00:00Z"
81
+ try:
82
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
83
+ except ValueError:
84
+ return value
85
+ if parsed.tzinfo is None:
86
+ parsed = parsed.replace(tzinfo=timezone.utc)
87
+ return parsed.astimezone(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
88
+
89
+
77
90
  def xml_file(urls: list[dict[str, str]]) -> str:
78
91
  rows = []
79
92
  for item in urls:
80
- lastmod = f"<lastmod>{escape(item['lastmod'])}</lastmod>" if item.get("lastmod") else ""
81
- 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()))
82
- rows.append(f"<url><loc>{escape(item['url'])}</loc>{lastmod}{alternate_xml}</url>")
83
- 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"
93
+ fields = [f" <loc>{escape(item['url'])}</loc>"]
94
+ if item.get("lastmod"):
95
+ fields.append(f" <lastmod>{escape(conventional_lastmod(item['lastmod']))}</lastmod>")
96
+ fields.extend(f' <xhtml:link rel="alternate" hreflang="{escape(str(lang))}" href="{escape(str(url))}" />' for lang, url in sorted((item.get("alternates") or {}).items()))
97
+ rows.append(" <url>\n" + "\n".join(fields) + "\n </url>")
98
+ body = "\n".join(rows)
99
+ 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">\n' + body + ('\n' if body else '') + '</urlset>\n'
84
100
 
85
101
 
86
102
  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:
@@ -100,11 +116,12 @@ def build_plan(routes: list[dict[str, str]], origin: str, content_types: set[str
100
116
  chunks.append({"filename": filename, "url": url, "entries": len(chunk_entries), "routes": chunk_entries, "bytes": len(xml.encode()), "sha256": hashlib.sha256(xml.encode()).hexdigest(), "xml": xml})
101
117
  sitemap_urls.append(url)
102
118
  group_plans.append({"contentType": content_type, "chunks": chunks})
103
- index_xml = '<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' + "".join(f"<sitemap><loc>{escape(url)}</loc></sitemap>" for url in sitemap_urls) + "</sitemapindex>\n"
119
+ index_rows = "\n".join(f" <sitemap>\n <loc>{escape(url)}</loc>\n </sitemap>" for url in sitemap_urls)
120
+ index_xml = '<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' + index_rows + ('\n' if index_rows else '') + '</sitemapindex>\n'
104
121
  current = set(sitemap_urls)
105
122
  old = set((previous or {}).get("sitemapUrls", []))
106
123
  redirects = [{"from": url, "to": origin.rstrip("/") + "/sitemap.xml", "reason": "previously advertised sitemap removed"} for url in sorted(old - current)]
107
- plan = {"schemaVersion": "maggie-seo-sitemap-plan.v1", "planId": "sitemap-" + datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S"), "origin": origin.rstrip("/"), "groups": group_plans, "index": {"url": origin.rstrip("/") + "/sitemap.xml", "bytes": len(index_xml.encode()), "xml": index_xml, "sitemapUrls": sitemap_urls}, "redirects": redirects, "excluded": excluded}
124
+ plan = {"schemaVersion": "maggie-seo-sitemap-plan.v1", "planId": "sitemap-" + datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S"), "origin": origin.rstrip("/"), "groups": group_plans, "index": {"url": origin.rstrip("/") + "/sitemap.xml", "bytes": len(index_xml.encode()), "xml": index_xml, "sitemapUrls": sitemap_urls, "responseHeaders": {"content-type": "text/xml; charset=utf-8", "x-robots-tag": "all"}}, "redirects": redirects, "excluded": excluded}
108
125
  plan["validation"] = validate_plan_data(plan)
109
126
  return plan
110
127
 
@@ -64,9 +64,15 @@ def compare(baseline: dict, report: dict) -> dict:
64
64
  changes.append({"url": url, "fields": fields})
65
65
  expected_content = baseline.get("content", {})
66
66
  content_changes = []
67
+ content_not_compared = []
67
68
  if expected_content:
68
69
  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
+ expected_contract = expected_content[url]
71
+ current_contract = current_content[url]
72
+ ignored_legacy_fields = {"images", "imageAlts"} if "images" not in expected_contract else set()
73
+ fields = sorted(key for key in expected_contract.keys() | current_contract.keys() if key not in ignored_legacy_fields and expected_contract.get(key) != current_contract.get(key))
74
+ if "images" not in expected_contract:
75
+ content_not_compared.append({"url": url, "fields": ["images", "imageAlts"], "reason": "baseline predates image content contract"})
70
76
  if fields:
71
77
  content_changes.append({"url": url, "fields": fields})
72
78
  missing_content = sorted(expected_content.keys() - current_content.keys())
@@ -75,6 +81,7 @@ def compare(baseline: dict, report: dict) -> dict:
75
81
  return {"passed": not errors and not added and not removed and not changes and not content_changes,
76
82
  "errors": errors, "added": added, "removed": removed, "changed": changes,
77
83
  "contentChanged": content_changes,
84
+ "contentNotCompared": content_not_compared,
78
85
  "excludedQueryUrls": query_urls}
79
86
 
80
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.10",
3
+ "version": "0.7.12",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",