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.
Files changed (44) hide show
  1. syntaxmatrix/__init__.py +3 -2
  2. syntaxmatrix/agentic/agents.py +1220 -169
  3. syntaxmatrix/agentic/agents_orchestrer.py +326 -0
  4. syntaxmatrix/agentic/code_tools_registry.py +27 -32
  5. syntaxmatrix/auth.py +142 -5
  6. syntaxmatrix/commentary.py +16 -16
  7. syntaxmatrix/core.py +192 -84
  8. syntaxmatrix/db.py +460 -4
  9. syntaxmatrix/{display.py → display_html.py} +2 -6
  10. syntaxmatrix/gpt_models_latest.py +1 -1
  11. syntaxmatrix/media/__init__.py +0 -0
  12. syntaxmatrix/media/media_pixabay.py +277 -0
  13. syntaxmatrix/models.py +1 -1
  14. syntaxmatrix/page_builder_defaults.py +183 -0
  15. syntaxmatrix/page_builder_generation.py +1122 -0
  16. syntaxmatrix/page_layout_contract.py +644 -0
  17. syntaxmatrix/page_patch_publish.py +1471 -0
  18. syntaxmatrix/preface.py +670 -0
  19. syntaxmatrix/profiles.py +28 -10
  20. syntaxmatrix/routes.py +1941 -593
  21. syntaxmatrix/selftest_page_templates.py +360 -0
  22. syntaxmatrix/settings/client_items.py +28 -0
  23. syntaxmatrix/settings/model_map.py +1022 -207
  24. syntaxmatrix/settings/prompts.py +328 -130
  25. syntaxmatrix/static/assets/hero-default.svg +22 -0
  26. syntaxmatrix/static/icons/bot-icon.png +0 -0
  27. syntaxmatrix/static/icons/favicon.png +0 -0
  28. syntaxmatrix/static/icons/logo.png +0 -0
  29. syntaxmatrix/static/icons/logo3.png +0 -0
  30. syntaxmatrix/templates/admin_branding.html +104 -0
  31. syntaxmatrix/templates/admin_features.html +63 -0
  32. syntaxmatrix/templates/admin_secretes.html +108 -0
  33. syntaxmatrix/templates/change_password.html +124 -0
  34. syntaxmatrix/templates/dashboard.html +296 -131
  35. syntaxmatrix/templates/dataset_resize.html +535 -0
  36. syntaxmatrix/templates/edit_page.html +2535 -0
  37. syntaxmatrix/utils.py +2728 -2835
  38. {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/METADATA +6 -2
  39. {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/RECORD +42 -25
  40. syntaxmatrix/generate_page.py +0 -634
  41. syntaxmatrix/static/icons/hero_bg.jpg +0 -0
  42. {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/WHEEL +0 -0
  43. {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/licenses/LICENSE.txt +0 -0
  44. {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