syntaxmatrix 2.5.5.5__py3-none-any.whl → 2.6.2__py3-none-any.whl
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.
- syntaxmatrix/__init__.py +3 -2
- syntaxmatrix/agentic/agents.py +1220 -169
- syntaxmatrix/agentic/agents_orchestrer.py +326 -0
- syntaxmatrix/agentic/code_tools_registry.py +27 -32
- syntaxmatrix/auth.py +142 -5
- syntaxmatrix/commentary.py +16 -16
- syntaxmatrix/core.py +192 -84
- syntaxmatrix/db.py +460 -4
- syntaxmatrix/{display.py → display_html.py} +2 -6
- syntaxmatrix/gpt_models_latest.py +1 -1
- syntaxmatrix/media/__init__.py +0 -0
- syntaxmatrix/media/media_pixabay.py +277 -0
- syntaxmatrix/models.py +1 -1
- syntaxmatrix/page_builder_defaults.py +183 -0
- syntaxmatrix/page_builder_generation.py +1122 -0
- syntaxmatrix/page_layout_contract.py +644 -0
- syntaxmatrix/page_patch_publish.py +1471 -0
- syntaxmatrix/preface.py +670 -0
- syntaxmatrix/profiles.py +28 -10
- syntaxmatrix/routes.py +1941 -593
- syntaxmatrix/selftest_page_templates.py +360 -0
- syntaxmatrix/settings/client_items.py +28 -0
- syntaxmatrix/settings/model_map.py +1022 -207
- syntaxmatrix/settings/prompts.py +328 -130
- syntaxmatrix/static/assets/hero-default.svg +22 -0
- syntaxmatrix/static/icons/bot-icon.png +0 -0
- syntaxmatrix/static/icons/favicon.png +0 -0
- syntaxmatrix/static/icons/logo.png +0 -0
- syntaxmatrix/static/icons/logo3.png +0 -0
- syntaxmatrix/templates/admin_branding.html +104 -0
- syntaxmatrix/templates/admin_features.html +63 -0
- syntaxmatrix/templates/admin_secretes.html +108 -0
- syntaxmatrix/templates/change_password.html +124 -0
- syntaxmatrix/templates/dashboard.html +296 -131
- syntaxmatrix/templates/dataset_resize.html +535 -0
- syntaxmatrix/templates/edit_page.html +2535 -0
- syntaxmatrix/utils.py +2728 -2835
- {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/METADATA +6 -2
- {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/RECORD +42 -25
- syntaxmatrix/generate_page.py +0 -634
- syntaxmatrix/static/icons/hero_bg.jpg +0 -0
- {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/WHEEL +0 -0
- {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/licenses/LICENSE.txt +0 -0
- {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
# syntaxmatrix/page_layout_contract.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
from bs4 import BeautifulSoup
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ─────────────────────────────────────────────────────────────
|
|
13
|
+
# Config: categories, templates, and allowed section types
|
|
14
|
+
# ─────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
KNOWN_CATEGORIES = {"about", "services", "blog", "landing", "contact", "docs", "careers"}
|
|
17
|
+
|
|
18
|
+
# Template → allowed section types
|
|
19
|
+
TEMPLATE_ALLOWED_TYPES: Dict[str, set[str]] = {
|
|
20
|
+
"about_glass_hero_v1": {
|
|
21
|
+
"hero", "story", "values", "logos", "team", "testimonials", "faq", "cta"
|
|
22
|
+
},
|
|
23
|
+
"services_grid_v1": {
|
|
24
|
+
"hero", "services", "process", "proof", "faq", "cta"
|
|
25
|
+
},
|
|
26
|
+
"services_detail_v1": {
|
|
27
|
+
"hero", "offers", "comparison", "process", "case_studies", "faq", "cta"
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
# Add more templates later...
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Template → canonical order (unknown types appended)
|
|
34
|
+
TEMPLATE_SECTION_ORDER: Dict[str, List[str]] = {
|
|
35
|
+
"about_glass_hero_v1": [
|
|
36
|
+
"sec_hero",
|
|
37
|
+
"sec_story",
|
|
38
|
+
"sec_values",
|
|
39
|
+
"sec_logos",
|
|
40
|
+
"sec_team",
|
|
41
|
+
"sec_testimonials",
|
|
42
|
+
"sec_faq",
|
|
43
|
+
"sec_cta",
|
|
44
|
+
],
|
|
45
|
+
"services_grid_v1": [
|
|
46
|
+
"sec_hero", "sec_services", "sec_process", "sec_proof", "sec_faq", "sec_cta"
|
|
47
|
+
],
|
|
48
|
+
"services_detail_v1": [
|
|
49
|
+
"sec_hero", "sec_offers", "sec_comparison", "sec_process", "sec_case_studies", "sec_faq", "sec_cta"
|
|
50
|
+
],
|
|
51
|
+
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Grid defaults by section type
|
|
55
|
+
DEFAULT_COLS_BY_TYPE = {
|
|
56
|
+
"values": 3,
|
|
57
|
+
"team": 3,
|
|
58
|
+
"logos": 5,
|
|
59
|
+
"testimonials": 3,
|
|
60
|
+
"faq": 2,
|
|
61
|
+
"cta": 2,
|
|
62
|
+
"services": 3,
|
|
63
|
+
"offers": 2,
|
|
64
|
+
"comparison": 3,
|
|
65
|
+
"process": 3,
|
|
66
|
+
"proof": 4,
|
|
67
|
+
"case_studies": 3,
|
|
68
|
+
"offers": 2,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# “Grid-ish” sections should have .grid in HTML (for your patcher)
|
|
72
|
+
GRID_SECTION_TYPES = {"values", "team", "logos", "testimonials", "faq", "cta", "features", "gallery",
|
|
73
|
+
"richtext", "services", "offers", "comparison", "process", "proof", "case_studies"
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Aliases → canonical section ids (About v1)
|
|
78
|
+
SECTION_ID_ALIASES = {
|
|
79
|
+
"hero": "sec_hero",
|
|
80
|
+
"header": "sec_hero",
|
|
81
|
+
"top": "sec_hero",
|
|
82
|
+
|
|
83
|
+
"about": "sec_story",
|
|
84
|
+
"intro": "sec_story",
|
|
85
|
+
"story": "sec_story",
|
|
86
|
+
|
|
87
|
+
"principles": "sec_values",
|
|
88
|
+
"highlights": "sec_values",
|
|
89
|
+
"values": "sec_values",
|
|
90
|
+
|
|
91
|
+
"clients": "sec_logos",
|
|
92
|
+
"partners": "sec_logos",
|
|
93
|
+
"logos": "sec_logos",
|
|
94
|
+
|
|
95
|
+
"people": "sec_team",
|
|
96
|
+
"founders": "sec_team",
|
|
97
|
+
"team": "sec_team",
|
|
98
|
+
|
|
99
|
+
"reviews": "sec_testimonials",
|
|
100
|
+
"testimonials": "sec_testimonials",
|
|
101
|
+
|
|
102
|
+
"questions": "sec_faq",
|
|
103
|
+
"faq": "sec_faq",
|
|
104
|
+
|
|
105
|
+
"contact": "sec_cta",
|
|
106
|
+
"next_steps": "sec_cta",
|
|
107
|
+
"cta": "sec_cta",
|
|
108
|
+
"services": "sec_services",
|
|
109
|
+
|
|
110
|
+
"offerings": "sec_services",
|
|
111
|
+
"process": "sec_process",
|
|
112
|
+
"how_we_work": "sec_process",
|
|
113
|
+
"proof": "sec_proof",
|
|
114
|
+
"results": "sec_proof",
|
|
115
|
+
"case_studies": "sec_case_studies",
|
|
116
|
+
"cases": "sec_case_studies",
|
|
117
|
+
"offers": "sec_offers",
|
|
118
|
+
"comparison": "sec_comparison",
|
|
119
|
+
"pricing": "sec_comparison",
|
|
120
|
+
"packages": "sec_offers",
|
|
121
|
+
"tiers": "sec_comparison",
|
|
122
|
+
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$", re.ASCII)
|
|
126
|
+
SAFE_ID_RE = re.compile(r"[^a-z0-9_:\-]+", re.ASCII)
|
|
127
|
+
DANGEROUS_RE = re.compile(
|
|
128
|
+
r"(<\s*script\b|javascript:|\bon\w+\s*=)", re.IGNORECASE
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class Issue:
|
|
134
|
+
level: str # "error" | "warning"
|
|
135
|
+
path: str
|
|
136
|
+
message: str
|
|
137
|
+
|
|
138
|
+
def to_dict(self) -> Dict[str, str]:
|
|
139
|
+
return {"level": self.level, "path": self.path, "message": self.message}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ─────────────────────────────────────────────────────────────
|
|
143
|
+
# Helpers
|
|
144
|
+
# ─────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
def _is_str(x: Any) -> bool:
|
|
147
|
+
return isinstance(x, str)
|
|
148
|
+
|
|
149
|
+
def _s(x: Any) -> str:
|
|
150
|
+
return x.strip() if isinstance(x, str) else ""
|
|
151
|
+
|
|
152
|
+
def _clean_ws(s: str) -> str:
|
|
153
|
+
s = s.replace("\x00", "")
|
|
154
|
+
s = re.sub(r"\s+", " ", s).strip()
|
|
155
|
+
return s
|
|
156
|
+
|
|
157
|
+
def _has_dangerous(s: str) -> bool:
|
|
158
|
+
return bool(DANGEROUS_RE.search(s or ""))
|
|
159
|
+
|
|
160
|
+
def _urlish(url: str) -> bool:
|
|
161
|
+
"""
|
|
162
|
+
Accept:
|
|
163
|
+
- http/https URLs
|
|
164
|
+
- root-relative (/uploads/.., /static/..)
|
|
165
|
+
- fragment (#sec_cta)
|
|
166
|
+
- relative (uploads/media/..., media/..., ./..., ../...)
|
|
167
|
+
"""
|
|
168
|
+
u = _s(url)
|
|
169
|
+
if not u:
|
|
170
|
+
return False
|
|
171
|
+
if u.startswith("#"):
|
|
172
|
+
return True
|
|
173
|
+
if u.startswith(("/", "./", "../")):
|
|
174
|
+
return True
|
|
175
|
+
if u.startswith(("uploads/", "media/", "static/")):
|
|
176
|
+
return True
|
|
177
|
+
p = urlparse(u)
|
|
178
|
+
return p.scheme in ("http", "https") and bool(p.netloc)
|
|
179
|
+
|
|
180
|
+
def _safe_id(raw: str) -> str:
|
|
181
|
+
s = _s(raw).lower()
|
|
182
|
+
s = s.replace(" ", "_")
|
|
183
|
+
s = SAFE_ID_RE.sub("", s)
|
|
184
|
+
s = s.strip("_")
|
|
185
|
+
return s
|
|
186
|
+
|
|
187
|
+
def _make_unique(existing_lower: set[str], base: str) -> str:
|
|
188
|
+
b = base
|
|
189
|
+
if b.lower() not in existing_lower:
|
|
190
|
+
existing_lower.add(b.lower())
|
|
191
|
+
return b
|
|
192
|
+
i = 2
|
|
193
|
+
while f"{b}_{i}".lower() in existing_lower:
|
|
194
|
+
i += 1
|
|
195
|
+
new_id = f"{b}_{i}"
|
|
196
|
+
existing_lower.add(new_id.lower())
|
|
197
|
+
return new_id
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ─────────────────────────────────────────────────────────────
|
|
201
|
+
# Normaliser
|
|
202
|
+
# ─────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
def normalise_layout(
|
|
205
|
+
layout: Any,
|
|
206
|
+
*,
|
|
207
|
+
default_category: str = "about",
|
|
208
|
+
default_template_id: str = "about_glass_hero_v1",
|
|
209
|
+
default_template_version: str = "1.0.0",
|
|
210
|
+
mode: str = "prod", # "prod" | "draft"
|
|
211
|
+
) -> Dict[str, Any]:
|
|
212
|
+
"""
|
|
213
|
+
Bring any agent/editor output into a stable contract shape.
|
|
214
|
+
This should run BEFORE validate_layout().
|
|
215
|
+
"""
|
|
216
|
+
if not isinstance(layout, dict):
|
|
217
|
+
layout = {}
|
|
218
|
+
|
|
219
|
+
out: Dict[str, Any] = dict(layout)
|
|
220
|
+
|
|
221
|
+
# Root defaults
|
|
222
|
+
out["category"] = _clean_ws(_s(out.get("category"))) or default_category
|
|
223
|
+
out["page"] = _clean_ws(_s(out.get("page"))) or out["category"]
|
|
224
|
+
|
|
225
|
+
tpl = out.get("template")
|
|
226
|
+
if not isinstance(tpl, dict):
|
|
227
|
+
tpl = {}
|
|
228
|
+
tpl_id = _clean_ws(_s(tpl.get("id"))) or default_template_id
|
|
229
|
+
tpl_ver = _clean_ws(_s(tpl.get("version"))) or default_template_version
|
|
230
|
+
tpl["id"] = tpl_id
|
|
231
|
+
tpl["version"] = tpl_ver
|
|
232
|
+
out["template"] = tpl
|
|
233
|
+
|
|
234
|
+
# Meta normalisation
|
|
235
|
+
meta = out.get("meta")
|
|
236
|
+
if not isinstance(meta, dict):
|
|
237
|
+
meta = {}
|
|
238
|
+
for k in ("pageTitle", "summary"):
|
|
239
|
+
if k in meta:
|
|
240
|
+
meta[k] = _clean_ws(_s(meta.get(k)))
|
|
241
|
+
# CTA defaults (safe)
|
|
242
|
+
def _norm_cta(cta: Any) -> Dict[str, str]:
|
|
243
|
+
if not isinstance(cta, dict):
|
|
244
|
+
return {}
|
|
245
|
+
return {"label": _clean_ws(_s(cta.get("label"))), "href": _clean_ws(_s(cta.get("href")))}
|
|
246
|
+
if "primaryCta" in meta:
|
|
247
|
+
meta["primaryCta"] = _norm_cta(meta["primaryCta"])
|
|
248
|
+
if "secondaryCta" in meta:
|
|
249
|
+
meta["secondaryCta"] = _norm_cta(meta["secondaryCta"])
|
|
250
|
+
out["meta"] = meta
|
|
251
|
+
|
|
252
|
+
# Sections list
|
|
253
|
+
secs = out.get("sections")
|
|
254
|
+
if not isinstance(secs, list):
|
|
255
|
+
secs = []
|
|
256
|
+
secs2: List[Dict[str, Any]] = []
|
|
257
|
+
existing_ids_lower: set[str] = set()
|
|
258
|
+
|
|
259
|
+
# First pass: normalise section objects
|
|
260
|
+
for idx, s in enumerate(secs):
|
|
261
|
+
if not isinstance(s, dict):
|
|
262
|
+
continue
|
|
263
|
+
s2 = dict(s)
|
|
264
|
+
|
|
265
|
+
stype = _safe_id(_s(s2.get("type")))
|
|
266
|
+
if not stype:
|
|
267
|
+
stype = "hero" if idx == 0 else "story"
|
|
268
|
+
s2["type"] = stype
|
|
269
|
+
|
|
270
|
+
# Id: alias mapping, then safe id, then ensure unique
|
|
271
|
+
raw_id = _s(s2.get("id"))
|
|
272
|
+
if raw_id:
|
|
273
|
+
sid = _safe_id(raw_id)
|
|
274
|
+
else:
|
|
275
|
+
sid = f"sec_{stype}"
|
|
276
|
+
|
|
277
|
+
# Apply alias mapping (only if canonical isn't already present later)
|
|
278
|
+
alias_key = _safe_id(raw_id) or stype
|
|
279
|
+
if alias_key in SECTION_ID_ALIASES:
|
|
280
|
+
sid = SECTION_ID_ALIASES[alias_key]
|
|
281
|
+
|
|
282
|
+
sid = _make_unique(existing_ids_lower, sid)
|
|
283
|
+
s2["id"] = sid
|
|
284
|
+
|
|
285
|
+
# Strings
|
|
286
|
+
s2["title"] = _clean_ws(_s(s2.get("title")))
|
|
287
|
+
s2["text"] = _clean_ws(_s(s2.get("text")))
|
|
288
|
+
|
|
289
|
+
# Items
|
|
290
|
+
items = s2.get("items")
|
|
291
|
+
if not isinstance(items, list):
|
|
292
|
+
items = []
|
|
293
|
+
items2: List[Dict[str, Any]] = []
|
|
294
|
+
for j, it in enumerate(items):
|
|
295
|
+
if not isinstance(it, dict):
|
|
296
|
+
continue
|
|
297
|
+
it2 = dict(it)
|
|
298
|
+
it2["id"] = _safe_id(_s(it2.get("id"))) or f"item_{sid}_{j+1}"
|
|
299
|
+
it2["type"] = _safe_id(_s(it2.get("type"))) or "card"
|
|
300
|
+
it2["title"] = _clean_ws(_s(it2.get("title")))
|
|
301
|
+
it2["text"] = _clean_ws(_s(it2.get("text")))
|
|
302
|
+
if "imageUrl" in it2:
|
|
303
|
+
it2["imageUrl"] = _clean_ws(_s(it2.get("imageUrl")))
|
|
304
|
+
items2.append(it2)
|
|
305
|
+
s2["items"] = items2
|
|
306
|
+
|
|
307
|
+
# Cols
|
|
308
|
+
cols = s2.get("cols")
|
|
309
|
+
if isinstance(cols, (int, float)):
|
|
310
|
+
cols_i = int(cols)
|
|
311
|
+
else:
|
|
312
|
+
cols_i = DEFAULT_COLS_BY_TYPE.get(stype, 0)
|
|
313
|
+
if cols_i:
|
|
314
|
+
s2["cols"] = max(1, min(5, cols_i))
|
|
315
|
+
elif "cols" in s2:
|
|
316
|
+
s2.pop("cols", None)
|
|
317
|
+
|
|
318
|
+
# Hero canonical image
|
|
319
|
+
if stype == "hero":
|
|
320
|
+
img = _clean_ws(_s(s2.get("imageUrl")))
|
|
321
|
+
if not img and items2 and isinstance(items2[0], dict):
|
|
322
|
+
img = _clean_ws(_s(items2[0].get("imageUrl")))
|
|
323
|
+
if img:
|
|
324
|
+
s2["imageUrl"] = img
|
|
325
|
+
# Back-compat: ensure items[0] exists and carries the image
|
|
326
|
+
if not items2:
|
|
327
|
+
s2["items"] = [{"id": "hero_media", "type": "card", "title": "Hero image", "text": "", "imageUrl": img}]
|
|
328
|
+
else:
|
|
329
|
+
if not _s(items2[0].get("imageUrl")):
|
|
330
|
+
items2[0]["imageUrl"] = img
|
|
331
|
+
|
|
332
|
+
secs2.append(s2)
|
|
333
|
+
|
|
334
|
+
# Enforce canonical ids for the About v1 hero if present
|
|
335
|
+
# If we have a hero type but id isn't sec_hero, rename it (safe) when possible.
|
|
336
|
+
hero_idxs = [i for i, s in enumerate(secs2) if (s.get("type") == "hero")]
|
|
337
|
+
if hero_idxs:
|
|
338
|
+
hi = hero_idxs[0]
|
|
339
|
+
if secs2[hi].get("id") != "sec_hero":
|
|
340
|
+
# Only rename if sec_hero not already taken
|
|
341
|
+
taken = {s.get("id") for s in secs2}
|
|
342
|
+
if "sec_hero" not in taken:
|
|
343
|
+
secs2[hi]["id"] = "sec_hero"
|
|
344
|
+
|
|
345
|
+
# Reorder using template canonical order (append unknowns)
|
|
346
|
+
order = TEMPLATE_SECTION_ORDER.get(tpl_id) or []
|
|
347
|
+
if order:
|
|
348
|
+
by_id = {s.get("id"): s for s in secs2}
|
|
349
|
+
ordered: List[Dict[str, Any]] = []
|
|
350
|
+
for sid in order:
|
|
351
|
+
if sid in by_id:
|
|
352
|
+
ordered.append(by_id.pop(sid))
|
|
353
|
+
# append whatever is left (original relative order)
|
|
354
|
+
for s in secs2:
|
|
355
|
+
sid = s.get("id")
|
|
356
|
+
if sid in by_id:
|
|
357
|
+
ordered.append(by_id.pop(sid))
|
|
358
|
+
secs2 = ordered
|
|
359
|
+
|
|
360
|
+
out["sections"] = secs2
|
|
361
|
+
return out
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# ─────────────────────────────────────────────────────────────
|
|
365
|
+
# Layout validator (15 checks)
|
|
366
|
+
# ─────────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
def validate_layout(layout: Dict[str, Any]) -> List[Issue]:
|
|
369
|
+
issues: List[Issue] = []
|
|
370
|
+
|
|
371
|
+
if not isinstance(layout, dict):
|
|
372
|
+
return [Issue("error", "$", "Layout must be an object.")]
|
|
373
|
+
|
|
374
|
+
# 1) Root shape
|
|
375
|
+
if not _is_str(layout.get("category")):
|
|
376
|
+
issues.append(Issue("error", "$.category", "Missing or invalid category (string required)."))
|
|
377
|
+
|
|
378
|
+
if not isinstance(layout.get("template"), dict):
|
|
379
|
+
issues.append(Issue("error", "$.template", "Missing or invalid template object."))
|
|
380
|
+
|
|
381
|
+
if not isinstance(layout.get("sections"), list):
|
|
382
|
+
issues.append(Issue("error", "$.sections", "Missing or invalid sections list."))
|
|
383
|
+
return issues
|
|
384
|
+
|
|
385
|
+
category = _s(layout.get("category"))
|
|
386
|
+
tpl = layout.get("template") if isinstance(layout.get("template"), dict) else {}
|
|
387
|
+
tpl_id = _s(tpl.get("id"))
|
|
388
|
+
tpl_ver = _s(tpl.get("version"))
|
|
389
|
+
|
|
390
|
+
# 2) Template identity
|
|
391
|
+
if not tpl_id:
|
|
392
|
+
issues.append(Issue("error", "$.template.id", "template.id must be a non-empty string."))
|
|
393
|
+
if tpl_ver and not VERSION_RE.match(tpl_ver):
|
|
394
|
+
issues.append(Issue("error", "$.template.version", "template.version must match X.Y.Z (e.g. 1.0.0)."))
|
|
395
|
+
if not tpl_ver:
|
|
396
|
+
issues.append(Issue("warning", "$.template.version", "template.version missing; defaulting is recommended."))
|
|
397
|
+
|
|
398
|
+
# 3) Category known
|
|
399
|
+
if category and category not in KNOWN_CATEGORIES:
|
|
400
|
+
issues.append(Issue("warning", "$.category", f"Unknown category '{category}'. Allowed: {sorted(KNOWN_CATEGORIES)}"))
|
|
401
|
+
|
|
402
|
+
secs: List[Any] = layout.get("sections") or []
|
|
403
|
+
|
|
404
|
+
# 4) Sections list length
|
|
405
|
+
if len(secs) < 1:
|
|
406
|
+
issues.append(Issue("error", "$.sections", "At least 1 section is required."))
|
|
407
|
+
|
|
408
|
+
# 5) Unique section ids
|
|
409
|
+
ids_lower: set[str] = set()
|
|
410
|
+
for i, s in enumerate(secs):
|
|
411
|
+
if not isinstance(s, dict):
|
|
412
|
+
issues.append(Issue("error", f"$.sections[{i}]", "Each section must be an object."))
|
|
413
|
+
continue
|
|
414
|
+
sid = _s(s.get("id"))
|
|
415
|
+
if not sid:
|
|
416
|
+
issues.append(Issue("error", f"$.sections[{i}].id", "Section id is required."))
|
|
417
|
+
continue
|
|
418
|
+
if sid.lower() in ids_lower:
|
|
419
|
+
issues.append(Issue("error", f"$.sections[{i}].id", f"Duplicate section id '{sid}'."))
|
|
420
|
+
ids_lower.add(sid.lower())
|
|
421
|
+
|
|
422
|
+
# 6) Valid section type (template-aware)
|
|
423
|
+
allowed_types = TEMPLATE_ALLOWED_TYPES.get(tpl_id)
|
|
424
|
+
for i, s in enumerate(secs):
|
|
425
|
+
if not isinstance(s, dict):
|
|
426
|
+
continue
|
|
427
|
+
st = _s(s.get("type")).lower()
|
|
428
|
+
if not st:
|
|
429
|
+
issues.append(Issue("error", f"$.sections[{i}].type", "Section type is required."))
|
|
430
|
+
continue
|
|
431
|
+
if allowed_types and st not in allowed_types:
|
|
432
|
+
issues.append(Issue("warning", f"$.sections[{i}].type", f"Type '{st}' not in allowed set for {tpl_id}."))
|
|
433
|
+
|
|
434
|
+
# 7) Exactly one hero
|
|
435
|
+
hero_secs = [s for s in secs if isinstance(s, dict) and _s(s.get("type")).lower() == "hero"]
|
|
436
|
+
if len(hero_secs) != 1:
|
|
437
|
+
issues.append(Issue("error", "$.sections", f"Exactly 1 hero section is required; found {len(hero_secs)}."))
|
|
438
|
+
|
|
439
|
+
# 8) Hero required fields
|
|
440
|
+
if hero_secs:
|
|
441
|
+
hero = hero_secs[0]
|
|
442
|
+
if not _s(hero.get("title")):
|
|
443
|
+
issues.append(Issue("error", "$.sections[hero].title", "Hero title is required."))
|
|
444
|
+
if not _s(hero.get("text")):
|
|
445
|
+
issues.append(Issue("error", "$.sections[hero].text", "Hero text is required."))
|
|
446
|
+
img = _s(hero.get("imageUrl"))
|
|
447
|
+
if not img:
|
|
448
|
+
# your patcher can fall back to items[0].imageUrl, but we still prefer hero.imageUrl
|
|
449
|
+
issues.append(Issue("error", "$.sections[hero].imageUrl", "Hero imageUrl is required."))
|
|
450
|
+
elif not _urlish(img):
|
|
451
|
+
issues.append(Issue("warning", "$.sections[hero].imageUrl", "Hero imageUrl does not look like a URL/path."))
|
|
452
|
+
|
|
453
|
+
# 9) Meta sanity + CTA fields
|
|
454
|
+
meta = layout.get("meta")
|
|
455
|
+
if meta is not None and not isinstance(meta, dict):
|
|
456
|
+
issues.append(Issue("error", "$.meta", "meta must be an object if present."))
|
|
457
|
+
if isinstance(meta, dict):
|
|
458
|
+
pt = _s(meta.get("pageTitle"))
|
|
459
|
+
if pt and len(pt) > 80:
|
|
460
|
+
issues.append(Issue("warning", "$.meta.pageTitle", "pageTitle is quite long (>80 chars)."))
|
|
461
|
+
for k in ("primaryCta", "secondaryCta"):
|
|
462
|
+
if k in meta:
|
|
463
|
+
cta = meta.get(k)
|
|
464
|
+
if not isinstance(cta, dict):
|
|
465
|
+
issues.append(Issue("error", f"$.meta.{k}", f"{k} must be an object with label/href."))
|
|
466
|
+
else:
|
|
467
|
+
if not _s(cta.get("label")) or not _s(cta.get("href")):
|
|
468
|
+
issues.append(Issue("error", f"$.meta.{k}", f"{k} requires non-empty label and href."))
|
|
469
|
+
|
|
470
|
+
# 10) Section title/text presence
|
|
471
|
+
title_optional_types = {"logos"} # adjust per template
|
|
472
|
+
for i, s in enumerate(secs):
|
|
473
|
+
if not isinstance(s, dict):
|
|
474
|
+
continue
|
|
475
|
+
st = _s(s.get("type")).lower()
|
|
476
|
+
title = s.get("title")
|
|
477
|
+
text = s.get("text")
|
|
478
|
+
if st != "hero" and st not in title_optional_types:
|
|
479
|
+
if not _is_str(title) or not _s(title):
|
|
480
|
+
issues.append(Issue("warning", f"$.sections[{i}].title", "Section title is missing or empty."))
|
|
481
|
+
if text is not None and not _is_str(text):
|
|
482
|
+
issues.append(Issue("error", f"$.sections[{i}].text", "Section text must be a string."))
|
|
483
|
+
|
|
484
|
+
# 11) Grid constraints (cols + items list)
|
|
485
|
+
for i, s in enumerate(secs):
|
|
486
|
+
if not isinstance(s, dict):
|
|
487
|
+
continue
|
|
488
|
+
cols = s.get("cols")
|
|
489
|
+
if cols is not None:
|
|
490
|
+
if not isinstance(cols, int) or not (1 <= cols <= 5):
|
|
491
|
+
issues.append(Issue("error", f"$.sections[{i}].cols", "cols must be an integer between 1 and 5."))
|
|
492
|
+
items = s.get("items")
|
|
493
|
+
if _s(s.get("type")).lower() in GRID_SECTION_TYPES:
|
|
494
|
+
if items is None or not isinstance(items, list):
|
|
495
|
+
issues.append(Issue("error", f"$.sections[{i}].items", "Grid-like section requires items as a list."))
|
|
496
|
+
elif len(items) == 0:
|
|
497
|
+
issues.append(Issue("warning", f"$.sections[{i}].items", "Section has no items."))
|
|
498
|
+
|
|
499
|
+
# 12) Items shape
|
|
500
|
+
for i, s in enumerate(secs):
|
|
501
|
+
if not isinstance(s, dict):
|
|
502
|
+
continue
|
|
503
|
+
items = s.get("items")
|
|
504
|
+
if not isinstance(items, list):
|
|
505
|
+
continue
|
|
506
|
+
for j, it in enumerate(items):
|
|
507
|
+
if not isinstance(it, dict):
|
|
508
|
+
issues.append(Issue("error", f"$.sections[{i}].items[{j}]", "Item must be an object."))
|
|
509
|
+
continue
|
|
510
|
+
for k in ("title", "text"):
|
|
511
|
+
if k in it and not _is_str(it.get(k)):
|
|
512
|
+
issues.append(Issue("error", f"$.sections[{i}].items[{j}].{k}", f"{k} must be a string."))
|
|
513
|
+
if "imageUrl" in it and it.get("imageUrl") is not None and not _is_str(it.get("imageUrl")):
|
|
514
|
+
issues.append(Issue("error", f"$.sections[{i}].items[{j}].imageUrl", "imageUrl must be a string."))
|
|
515
|
+
|
|
516
|
+
# 13) Item count guidelines (warnings)
|
|
517
|
+
def _warn_count(stype: str, mn: int, mx: int):
|
|
518
|
+
for i, s in enumerate(secs):
|
|
519
|
+
if not isinstance(s, dict):
|
|
520
|
+
continue
|
|
521
|
+
if _s(s.get("type")).lower() != stype:
|
|
522
|
+
continue
|
|
523
|
+
items = s.get("items") if isinstance(s.get("items"), list) else []
|
|
524
|
+
n = len(items)
|
|
525
|
+
if n < mn or n > mx:
|
|
526
|
+
issues.append(Issue("warning", f"$.sections[{i}].items", f"{stype} item count {n} outside recommended {mn}–{mx}."))
|
|
527
|
+
|
|
528
|
+
_warn_count("values", 2, 6)
|
|
529
|
+
_warn_count("logos", 2, 12)
|
|
530
|
+
_warn_count("team", 1, 12)
|
|
531
|
+
_warn_count("faq", 2, 12)
|
|
532
|
+
_warn_count("testimonials", 1, 9)
|
|
533
|
+
|
|
534
|
+
# 14) Dangerous HTML checks (errors)
|
|
535
|
+
def _scan_value(path: str, v: Any):
|
|
536
|
+
if isinstance(v, str) and _has_dangerous(v):
|
|
537
|
+
issues.append(Issue("error", path, "Potentially unsafe HTML/JS content detected."))
|
|
538
|
+
elif isinstance(v, dict):
|
|
539
|
+
for kk, vv in v.items():
|
|
540
|
+
_scan_value(f"{path}.{kk}", vv)
|
|
541
|
+
elif isinstance(v, list):
|
|
542
|
+
for idx, vv in enumerate(v):
|
|
543
|
+
_scan_value(f"{path}[{idx}]", vv)
|
|
544
|
+
|
|
545
|
+
_scan_value("$", layout)
|
|
546
|
+
|
|
547
|
+
# 15) Stable IDs for patching (warnings)
|
|
548
|
+
# If you want strict enforcement per template, change warnings → errors.
|
|
549
|
+
if tpl_id in TEMPLATE_SECTION_ORDER:
|
|
550
|
+
canonical = set(TEMPLATE_SECTION_ORDER[tpl_id])
|
|
551
|
+
for i, s in enumerate(secs):
|
|
552
|
+
if not isinstance(s, dict):
|
|
553
|
+
continue
|
|
554
|
+
sid = _s(s.get("id"))
|
|
555
|
+
if sid.startswith("sec_") and sid not in canonical and _s(s.get("type")).lower() != "hero":
|
|
556
|
+
issues.append(Issue("warning", f"$.sections[{i}].id", f"Non-canonical section id '{sid}' for template {tpl_id}."))
|
|
557
|
+
|
|
558
|
+
return issues
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
# ─────────────────────────────────────────────────────────────
|
|
562
|
+
# Compiled HTML validator (anchors your patcher needs)
|
|
563
|
+
# ─────────────────────────────────────────────────────────────
|
|
564
|
+
|
|
565
|
+
def validate_compiled_html(html: str, layout: Dict[str, Any]) -> List[Issue]:
|
|
566
|
+
issues: List[Issue] = []
|
|
567
|
+
if not isinstance(html, str) or not html.strip():
|
|
568
|
+
return [Issue("error", "$html", "Compiled HTML is empty or invalid.")]
|
|
569
|
+
|
|
570
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
571
|
+
|
|
572
|
+
# A) hero anchors
|
|
573
|
+
hero = None
|
|
574
|
+
secs = layout.get("sections") if isinstance(layout.get("sections"), list) else []
|
|
575
|
+
for s in secs:
|
|
576
|
+
if isinstance(s, dict) and _s(s.get("type")).lower() == "hero":
|
|
577
|
+
hero = s
|
|
578
|
+
break
|
|
579
|
+
|
|
580
|
+
if hero:
|
|
581
|
+
hero_id = _s(hero.get("id"))
|
|
582
|
+
hero_tag = soup.find("section", id=hero_id) if hero_id else None
|
|
583
|
+
if hero_tag is None:
|
|
584
|
+
issues.append(Issue("error", "$html.hero", f"Hero <section id=\"{hero_id}\"> not found in HTML."))
|
|
585
|
+
else:
|
|
586
|
+
has_bg = hero_tag.select_one(".hero-bg") is not None
|
|
587
|
+
has_img = hero_tag.find("img") is not None
|
|
588
|
+
if not (has_bg or has_img):
|
|
589
|
+
issues.append(Issue("error", "$html.hero.image", "Hero must contain .hero-bg or an <img>."))
|
|
590
|
+
|
|
591
|
+
if hero_tag.find("h1") is None:
|
|
592
|
+
issues.append(Issue("error", "$html.hero.h1", "Hero must contain an <h1>."))
|
|
593
|
+
|
|
594
|
+
if hero_tag.select_one("p.lead") is None:
|
|
595
|
+
issues.append(Issue("warning", "$html.hero.lead", "Hero should contain <p class=\"lead\"> for reliable patching."))
|
|
596
|
+
|
|
597
|
+
# B) section ids exist
|
|
598
|
+
for i, s in enumerate(secs):
|
|
599
|
+
if not isinstance(s, dict):
|
|
600
|
+
continue
|
|
601
|
+
sid = _s(s.get("id"))
|
|
602
|
+
if not sid:
|
|
603
|
+
continue
|
|
604
|
+
if soup.find("section", id=sid) is None:
|
|
605
|
+
issues.append(Issue("error", f"$html.sections[{i}]", f"<section id=\"{sid}\"> not found in HTML."))
|
|
606
|
+
|
|
607
|
+
# C) non-hero patch targets: h2 + intro p (warn if missing)
|
|
608
|
+
for i, s in enumerate(secs):
|
|
609
|
+
if not isinstance(s, dict):
|
|
610
|
+
continue
|
|
611
|
+
if _s(s.get("type")).lower() == "hero":
|
|
612
|
+
continue
|
|
613
|
+
sid = _s(s.get("id"))
|
|
614
|
+
sec_tag = soup.find("section", id=sid) if sid else None
|
|
615
|
+
if sec_tag is None:
|
|
616
|
+
continue
|
|
617
|
+
h2 = sec_tag.find("h2")
|
|
618
|
+
if h2 is None:
|
|
619
|
+
issues.append(Issue("warning", f"$html.sections[{i}].h2", f"Section '{sid}' has no <h2> (patcher may skip title)."))
|
|
620
|
+
continue
|
|
621
|
+
# intro paragraph right after h2 is ideal, but patcher can insert if missing
|
|
622
|
+
# so keep this as warning
|
|
623
|
+
expected_text = _s(s.get("text"))
|
|
624
|
+
nxt = h2.find_next_sibling()
|
|
625
|
+
if expected_text:
|
|
626
|
+
if nxt is None or nxt.name != "p":
|
|
627
|
+
issues.append(Issue("warning", f"$html.sections[{i}].intro_p",
|
|
628
|
+
f"Section '{sid}' has no <p> immediately after <h2>."))
|
|
629
|
+
|
|
630
|
+
# D) grid container exists for grid sections
|
|
631
|
+
for i, s in enumerate(secs):
|
|
632
|
+
if not isinstance(s, dict):
|
|
633
|
+
continue
|
|
634
|
+
st = _s(s.get("type")).lower()
|
|
635
|
+
if st not in GRID_SECTION_TYPES:
|
|
636
|
+
continue
|
|
637
|
+
sid = _s(s.get("id"))
|
|
638
|
+
sec_tag = soup.find("section", id=sid) if sid else None
|
|
639
|
+
if sec_tag is None:
|
|
640
|
+
continue
|
|
641
|
+
if sec_tag.select_one(".grid") is None:
|
|
642
|
+
issues.append(Issue("warning", f"$html.sections[{i}].grid", f"Grid-like section '{sid}' has no .grid container."))
|
|
643
|
+
|
|
644
|
+
return issues
|