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