@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 +35 -9
- package/bin/maggie.js +6 -2
- package/bundled-skills/maggie-dash/SKILL.md +19 -0
- package/bundled-skills/maggie-deployment/references/vps.md +33 -0
- package/bundled-skills/maggie-design/SKILL.md +26 -6
- package/bundled-skills/maggie-feedback/SKILL.md +2 -1
- package/bundled-skills/maggie-seo-geo/SKILL.md +20 -0
- package/bundled-templates/maggiedash/section-fanout.json +11 -0
- package/bundled-templates/maggiedash/section-registry.json +13 -0
- package/bundled-tools/clis/maggie_dash.py +39 -3
- package/bundled-tools/clis/maggie_design.py +134 -5
- package/bundled-tools/clis/maggie_feedback.py +3 -0
- package/bundled-tools/clis/site_audit.py +28 -1
- package/bundled-tools/runtime/maggie_sections.py +105 -0
- package/bundled-tools/runtime/maggie_sitemap.py +23 -6
- package/bundled-tools/runtime/site_baseline.py +8 -1
- package/package.json +1 -1
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.
|
|
222
|
-
npx @topy-ai/maggie@0.7.
|
|
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.
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
|
501
|
-
|
|
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.
|
|
14
|
-
|
|
15
|
-
|
|
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.
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|