@topy-ai/maggie 0.7.0 → 0.7.1
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 +22 -7
- package/bin/maggie.js +6 -6
- package/bundled-references/maggiedash-dashboard-ui.md +28 -0
- package/bundled-skills/maggie-blog/SKILL.md +12 -0
- package/bundled-skills/maggie-content-localization/SKILL.md +9 -0
- package/bundled-skills/maggie-dash/SKILL.md +21 -0
- package/bundled-skills/maggie-deployment/SKILL.md +17 -0
- package/bundled-skills/maggie-ops/SKILL.md +27 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +21 -1
- package/bundled-skills/maggie-service-booking/SKILL.md +6 -0
- package/bundled-templates/maggiedash/README.md +4 -0
- package/bundled-templates/maggiedash/dashboard-ui-contract.json +31 -0
- package/bundled-tools/clis/maggie_analytics.py +14 -1
- package/bundled-tools/clis/maggie_blog.py +6 -0
- package/bundled-tools/clis/maggie_dash.py +54 -0
- package/bundled-tools/clis/maggie_deployment.py +43 -0
- package/bundled-tools/clis/maggie_feedback.py +16 -1
- package/bundled-tools/clis/maggie_ops.py +21 -1
- package/bundled-tools/clis/maggie_service_booking.py +6 -3
- package/bundled-tools/clis/maggie_sitemap.py +16 -2
- package/bundled-tools/runtime/analytics_traffic.py +30 -0
- package/bundled-tools/runtime/content_localization.py +63 -1
- package/bundled-tools/runtime/dependency_lock.py +43 -0
- package/bundled-tools/runtime/integration_state.py +17 -0
- package/bundled-tools/runtime/maggie_dash_store.py +150 -6
- package/bundled-tools/runtime/maggie_dash_ui.py +60 -0
- package/bundled-tools/runtime/maggie_sitemap.py +50 -4
- package/bundled-tools/runtime/route_imports.py +51 -0
- package/bundled-tools/runtime/seed_evidence.py +25 -0
- package/package.json +1 -1
- package/references/maggiedash-dashboard-ui.md +28 -0
|
@@ -55,6 +55,12 @@ def filter_routes(routes: list[dict[str, str]], origin: str, content_types: set[
|
|
|
55
55
|
reason = "content type not enabled"
|
|
56
56
|
elif not absolute_url(url, origin):
|
|
57
57
|
reason = "URL must be an absolute HTTP(S) URL on the approved origin"
|
|
58
|
+
elif route.get("indexable") is False:
|
|
59
|
+
reason = "route is explicitly non-indexable"
|
|
60
|
+
elif route.get("canonicalUrl") and route.get("canonicalUrl") != url:
|
|
61
|
+
reason = "route canonical URL does not self-canonicalize"
|
|
62
|
+
elif route.get("searchable") is False:
|
|
63
|
+
reason = "route is explicitly not searchable"
|
|
58
64
|
elif EXCLUDED_PATHS.search(parsed.path) or any(key in parse_qs(parsed.query) for key in ("q", "search", "filter", "page")):
|
|
59
65
|
reason = "search, filter, or non-canonical route"
|
|
60
66
|
elif route.get("type", "").lower() in {"rss", "atom", "feed"}:
|
|
@@ -86,12 +92,12 @@ def build_plan(routes: list[dict[str, str]], origin: str, content_types: set[str
|
|
|
86
92
|
for content_type in sorted(content_types):
|
|
87
93
|
chunks = []
|
|
88
94
|
entries = groups.get(content_type, []) or []
|
|
89
|
-
for index in range(0,
|
|
95
|
+
for index in range(0, len(entries), chunk_target):
|
|
90
96
|
chunk_entries = entries[index:index + chunk_target]
|
|
91
97
|
filename = f"sitemap-{content_type}-{index // chunk_target + 1}.xml"
|
|
92
98
|
xml = xml_file(chunk_entries)
|
|
93
99
|
url = origin.rstrip("/") + "/" + filename
|
|
94
|
-
chunks.append({"filename": filename, "url": url, "entries": len(chunk_entries), "bytes": len(xml.encode()), "sha256": hashlib.sha256(xml.encode()).hexdigest(), "xml": xml})
|
|
100
|
+
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})
|
|
95
101
|
sitemap_urls.append(url)
|
|
96
102
|
group_plans.append({"contentType": content_type, "chunks": chunks})
|
|
97
103
|
index_xml = '<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' + "".join(f"<sitemap><loc>{escape(url)}</loc></sitemap>" for url in sitemap_urls) + "</sitemapindex>\n"
|
|
@@ -103,7 +109,7 @@ def build_plan(routes: list[dict[str, str]], origin: str, content_types: set[str
|
|
|
103
109
|
return plan
|
|
104
110
|
|
|
105
111
|
|
|
106
|
-
def validate_plan_data(plan: dict) -> dict:
|
|
112
|
+
def validate_plan_data(plan: dict, strict_semantic: bool = False) -> dict:
|
|
107
113
|
errors = []
|
|
108
114
|
warnings = []
|
|
109
115
|
origin = plan.get("origin", "")
|
|
@@ -117,6 +123,21 @@ def validate_plan_data(plan: dict) -> dict:
|
|
|
117
123
|
errors.append(f"chunk exceeds URL limit: {chunk.get('filename')}")
|
|
118
124
|
if chunk.get("bytes", 0) > HARD_BYTES_LIMIT:
|
|
119
125
|
errors.append(f"chunk exceeds byte limit: {chunk.get('filename')}")
|
|
126
|
+
for route in chunk.get("routes", []):
|
|
127
|
+
if route.get("indexable") is False or route.get("searchable") is False:
|
|
128
|
+
errors.append(f"non-indexable/searchable route was emitted: {route.get('url')}")
|
|
129
|
+
if route.get("canonicalUrl") and route.get("canonicalUrl") != route.get("url"):
|
|
130
|
+
errors.append(f"non-self-canonical route was emitted: {route.get('url')}")
|
|
131
|
+
if "contentWords" in route and int(route.get("contentWords") or 0) < 1:
|
|
132
|
+
errors.append(f"route has no searchable content evidence: {route.get('url')}")
|
|
133
|
+
if "title" in route and not str(route.get("title") or "").strip():
|
|
134
|
+
errors.append(f"route has no title evidence: {route.get('url')}")
|
|
135
|
+
if route.get("lastmod") and route.get("lastmodSource") in {"updated_at", "sync", "pull", "deploy"}:
|
|
136
|
+
errors.append(f"lastmod is derived from an operational timestamp: {route.get('url')}")
|
|
137
|
+
if route.get("lastmod") and route.get("lastmodSource") not in {None, "content-change", "source-revision"}:
|
|
138
|
+
errors.append(f"lastmod provenance is not a content change: {route.get('url')}")
|
|
139
|
+
if route.get("lastmod") and strict_semantic and route.get("lastmodSource") not in {"content-change", "source-revision"}:
|
|
140
|
+
errors.append(f"lastmod evidence is required for strict semantic validation: {route.get('url')}")
|
|
120
141
|
try:
|
|
121
142
|
root = ElementTree.fromstring(chunk.get("xml", ""))
|
|
122
143
|
locs = [element.text or "" for element in root.iter() if element.tag.endswith("loc")]
|
|
@@ -140,5 +161,30 @@ def validate_plan_data(plan: dict) -> dict:
|
|
|
140
161
|
except ElementTree.ParseError:
|
|
141
162
|
errors.append("sitemap index is not valid XML")
|
|
142
163
|
if not expected_urls:
|
|
143
|
-
warnings.append("no sitemap chunks generated")
|
|
164
|
+
warnings.append("no sitemap chunks generated; empty content types are not advertised")
|
|
165
|
+
if strict_semantic:
|
|
166
|
+
for group in plan.get("groups", []):
|
|
167
|
+
for chunk in group.get("chunks", []):
|
|
168
|
+
for route in chunk.get("routes", []):
|
|
169
|
+
if not str(route.get("title") or "").strip(): warnings.append(f"semantic title evidence missing: {route.get('url')}")
|
|
170
|
+
if "contentWords" not in route: warnings.append(f"semantic content evidence missing: {route.get('url')}")
|
|
171
|
+
if warnings:
|
|
172
|
+
errors.extend(warnings)
|
|
144
173
|
return {"status": "pass" if not errors else "fail", "errors": errors, "warnings": warnings}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def agent_files(routes: list[dict[str, str]], origin: str, locale: str = "en") -> dict[str, str]:
|
|
177
|
+
"""Generate compact, locale-aware agent discovery documents."""
|
|
178
|
+
visible = [route for route in routes if route.get("indexable") is not False and route.get("searchable") is not False and absolute_url(route.get("url", ""), origin)]
|
|
179
|
+
visible.sort(key=lambda route: route.get("url", ""))
|
|
180
|
+
lines = [f"# {origin} ({locale})", "", "This file lists indexable public routes for agent discovery.", "", "## Routes"]
|
|
181
|
+
for route in visible:
|
|
182
|
+
label = route.get("title") or route.get("url", "").rstrip("/").rsplit("/", 1)[-1] or "home"
|
|
183
|
+
lines.append(f"- [{label}]({route['url']})")
|
|
184
|
+
sitemap = [f"# Sitemap ({locale})", "", f"- XML sitemap: {origin.rstrip('/')}/sitemap.xml", "", "## Public routes"]
|
|
185
|
+
sitemap.extend(f"- {route['url']}" for route in visible)
|
|
186
|
+
insights = [f"# Content insights ({locale})", "", f"- Indexable routes: {len(visible)}", f"- Content types: {', '.join(sorted({str(route.get('type', 'unknown')) for route in visible})) or 'none'}", "", "## Freshness"]
|
|
187
|
+
for route in visible:
|
|
188
|
+
if route.get("lastmod"):
|
|
189
|
+
insights.append(f"- {route['url']}: last content change {route['lastmod']}")
|
|
190
|
+
return {"llms.txt": "\n".join(lines) + "\n", "sitemap.md": "\n".join(sitemap) + "\n", "insights.md": "\n".join(insights) + "\n"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Resolve route components from source imports, never from sibling basenames."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
IMPORT_RE = re.compile(r"(?:import\s+(?:[^;\n]*?\s+from\s+)?|export\s+[^;\n]*?\s+from\s+)[\"']([^\"']+)[\"']")
|
|
11
|
+
EXTENSIONS = ("", ".astro", ".tsx", ".jsx", ".ts", ".js", ".vue", ".svelte", ".html")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def resolve_import(route: Path, specifier: str) -> Path | None:
|
|
15
|
+
if not specifier.startswith("."):
|
|
16
|
+
return None
|
|
17
|
+
base = (route.parent / specifier).resolve()
|
|
18
|
+
candidates = [base]
|
|
19
|
+
candidates.extend(Path(str(base) + extension) for extension in EXTENSIONS[1:])
|
|
20
|
+
candidates.extend(base / f"index{extension}" for extension in EXTENSIONS)
|
|
21
|
+
for candidate in candidates:
|
|
22
|
+
if candidate.is_file():
|
|
23
|
+
return candidate
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def imported_components(route: Path) -> list[dict[str, str | None]]:
|
|
28
|
+
"""Return only source imports that resolve inside the project.
|
|
29
|
+
|
|
30
|
+
Bare package imports are intentionally recorded without a local path; they
|
|
31
|
+
are dependencies, not route-local components. The route source remains
|
|
32
|
+
the authority, so a same-named sibling file is never selected implicitly.
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
source = route.read_text(encoding="utf-8", errors="replace")
|
|
36
|
+
except OSError:
|
|
37
|
+
return []
|
|
38
|
+
result = []
|
|
39
|
+
seen = set()
|
|
40
|
+
for specifier in IMPORT_RE.findall(source):
|
|
41
|
+
resolved = resolve_import(route, specifier)
|
|
42
|
+
key = (specifier, str(resolved) if resolved else None)
|
|
43
|
+
if key in seen:
|
|
44
|
+
continue
|
|
45
|
+
seen.add(key)
|
|
46
|
+
result.append({
|
|
47
|
+
"specifier": specifier,
|
|
48
|
+
"path": str(resolved) if resolved else None,
|
|
49
|
+
"fingerprint": hashlib.sha256(resolved.read_bytes()).hexdigest() if resolved else None,
|
|
50
|
+
})
|
|
51
|
+
return result
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Validation for explicit, sanitized development fixture evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
SECRET = re.compile(r"(?:secret|token|password|api[_-]?key|database_url)", re.I)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def validate_manifest(value: object) -> dict:
|
|
12
|
+
errors = []
|
|
13
|
+
if not isinstance(value, dict):
|
|
14
|
+
return {"schemaVersion": "maggie-seed-manifest.v1", "passed": False, "errors": ["manifest must be an object"]}
|
|
15
|
+
if value.get("schemaVersion") != "maggie-seed-manifest.v1": errors.append("unsupported schemaVersion")
|
|
16
|
+
if value.get("optIn") is not True: errors.append("optIn must be true")
|
|
17
|
+
if value.get("sanitized") is not True: errors.append("sanitized must be true")
|
|
18
|
+
fixtures = value.get("fixtures")
|
|
19
|
+
if not isinstance(fixtures, list) or not fixtures: errors.append("fixtures must be a non-empty list")
|
|
20
|
+
for item in fixtures if isinstance(fixtures, list) else []:
|
|
21
|
+
if not isinstance(item, dict) or not item.get("name") or not item.get("rows"):
|
|
22
|
+
errors.append("each fixture requires name and non-empty rows")
|
|
23
|
+
if SECRET.search(str(item)):
|
|
24
|
+
errors.append("fixture contains a secret-like field")
|
|
25
|
+
return {"schemaVersion": "maggie-seed-manifest.v1", "passed": not errors, "errors": errors, "fixtureCount": len(fixtures) if isinstance(fixtures, list) else 0}
|
package/package.json
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# MaggieDash dashboard UI contract
|
|
2
|
+
|
|
3
|
+
MaggieDash workspaces use the lightweight contract in
|
|
4
|
+
`templates/maggiedash/dashboard-ui-contract.json`. It is provider-neutral and
|
|
5
|
+
describes the dashboard chrome, not a framework-specific component library.
|
|
6
|
+
|
|
7
|
+
The reference is the users workspace: one `WorkspaceBar`, one sidebar/content
|
|
8
|
+
navigation relationship, and one `ContentTabs` row. A route must not render a
|
|
9
|
+
second horizontal navigation, duplicate its sidebar, or hide duplicate markup
|
|
10
|
+
with CSS. `WorkspaceCard` owns card framing; page routes own content.
|
|
11
|
+
|
|
12
|
+
`ContentTabs` accepts only the props it renders (`tabs`, `active`, `actions`,
|
|
13
|
+
and `children`). It does not accept a page `title` or `description`. If a page
|
|
14
|
+
needs a heading or description, render a dedicated page header or use
|
|
15
|
+
`WorkspaceBar` explicitly. This keeps component APIs honest and prevents
|
|
16
|
+
dead-copy drift.
|
|
17
|
+
|
|
18
|
+
Validate a host implementation with:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
maggie dash ui validate --contract templates/maggiedash/dashboard-ui-contract.json \
|
|
22
|
+
--source src/components/WorkspaceBar.tsx \
|
|
23
|
+
--source src/components/WorkspaceCard.tsx \
|
|
24
|
+
--source src/components/ContentTabs.tsx
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The check is static evidence. It does not replace an authenticated browser
|
|
28
|
+
review of the rendered dashboard at desktop and mobile sizes.
|