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,1471 @@
1
+ from __future__ import annotations
2
+ import re
3
+ from typing import Any, Dict, Tuple, List, Optional
4
+ from bs4 import BeautifulSoup
5
+
6
+
7
+ _SECTION_BY_ID_RE = r'(<section\b[^>]*\bid="{sid}"[^>]*>)(.*?)(</section>)'
8
+ _SECTION_OPEN_RE = re.compile(r"<section\b[^>]*\bid=['\"]([^'\"]+)['\"][^>]*>", re.IGNORECASE)
9
+
10
+ def _safe_href(h: str) -> str:
11
+ """
12
+ Sanitise optional links.
13
+ IMPORTANT: empty/unsafe must return "" so CTAs are NOT created by default.
14
+ """
15
+ h = (h or "").strip()
16
+ if not h:
17
+ return ""
18
+ low = h.lower().strip()
19
+ if low.startswith("javascript:") or low.startswith("data:"):
20
+ return ""
21
+ return h
22
+
23
+
24
+ def _is_button_item(it: Dict[str, Any]) -> bool:
25
+ return ((it.get("type") or "").strip().lower() == "button")
26
+
27
+
28
+ def _button_node(soup, it: Dict[str, Any]):
29
+ label = (it.get("title") or "").strip() or "Button"
30
+ href = _safe_href(it.get("href") or it.get("url") or it.get("link") or "#")
31
+
32
+ wrap = soup.new_tag("div")
33
+ wrap["class"] = ["smx-action", "reveal"]
34
+
35
+ a = soup.new_tag("a")
36
+ a["class"] = ["smx-btn", "primary"]
37
+ a["data-smx"] = "button"
38
+ a["href"] = href
39
+ a.string = label
40
+
41
+ wrap.append(a)
42
+ return wrap
43
+
44
+
45
+ def _build_empty_section(soup, s: Dict[str, Any]) -> Any:
46
+ sid = (s.get("id") or "").strip() or "sec_new"
47
+ title = (s.get("title") or "").strip() or "New section"
48
+ text = (s.get("text") or "").strip()
49
+
50
+ sec = soup.new_tag("section")
51
+ sec["id"] = sid
52
+ sec["class"] = ["sec"]
53
+
54
+ wrap = soup.new_tag("div")
55
+ wrap["class"] = ["wrap"]
56
+ sec.append(wrap)
57
+
58
+ h2 = soup.new_tag("h2")
59
+ h2["class"] = ["reveal"]
60
+ h2.string = title
61
+ wrap.append(h2)
62
+
63
+ if text:
64
+ p = soup.new_tag("p")
65
+ p["class"] = ["reveal"]
66
+ p["style"] = "margin-bottom:14px;"
67
+ p.string = text
68
+ wrap.append(p)
69
+
70
+ grid = soup.new_tag("div")
71
+ grid["class"] = ["grid"]
72
+ grid["style"] = "grid-template-columns:repeat(3, minmax(0,1fr));"
73
+ wrap.append(grid)
74
+
75
+ return sec
76
+
77
+
78
+ def _esc_html(s: str) -> str:
79
+ s = s or ""
80
+ return (
81
+ s.replace("&", "&amp;")
82
+ .replace("<", "&lt;")
83
+ .replace(">", "&gt;")
84
+ .replace('"', "&quot;")
85
+ .replace("'", "&#39;")
86
+ )
87
+
88
+
89
+ def _is_button_item(it: Dict[str, Any]) -> bool:
90
+ return ((it.get("type") or "").strip().lower() == "button")
91
+
92
+ def _button_node(soup, it: Dict[str, Any]):
93
+ label = (it.get("title") or "").strip() or "Read more"
94
+ href = _safe_href(it.get("href") or "#") or "#"
95
+
96
+ box = soup.new_tag("div")
97
+ box["class"] = ["reveal"]
98
+ box["data-smx"] = "action"
99
+
100
+ a = soup.new_tag("a")
101
+ a["href"] = href
102
+ a["data-smx"] = "button"
103
+ a["style"] = (
104
+ "display:inline-flex;align-items:center;justify-content:center;"
105
+ "border-radius:999px;padding:10px 16px;"
106
+ "border:1px solid rgba(129,140,248,.7);"
107
+ "background:rgba(79,70,229,.95);"
108
+ "color:#e5e7eb;text-decoration:none;font-weight:600;"
109
+ )
110
+ a.string = label
111
+ box.append(a)
112
+ return box
113
+
114
+
115
+ def _existing_section_ids(html: str) -> set:
116
+ if not html:
117
+ return set()
118
+ return set(m.group(1) for m in _SECTION_OPEN_RE.finditer(html))
119
+
120
+
121
+ def _find_section_block_span(html: str, sid: str) -> Tuple[int, int]:
122
+ """
123
+ Returns (start, end) span of <section ... id="sid">...</section> if found, else (-1, -1).
124
+ Non-greedy to avoid eating across sections.
125
+ """
126
+ if not html or not sid:
127
+ return (-1, -1)
128
+ pat = re.compile(
129
+ r"(<section\b[^>]*\bid=['\"]" + re.escape(sid) + r"['\"][^>]*>.*?</section>)",
130
+ re.IGNORECASE | re.DOTALL,
131
+ )
132
+ m = pat.search(html)
133
+ if not m:
134
+ return (-1, -1)
135
+ return (m.start(1), m.end(1))
136
+
137
+
138
+ def _insert_before_wrapper_close(html: str, chunk: str) -> str:
139
+ """
140
+ Best-effort insert inside the outer smx wrapper if present, else before </body>, else append.
141
+ Assumes typical stored pages end with the wrapper closing </div>.
142
+ """
143
+ if not html:
144
+ return chunk
145
+
146
+ # Prefer inserting before the LAST closing </div> if we see an smx wrapper.
147
+ if re.search(r"<div\b[^>]*\bid=['\"]smx-page-[^'\"]+['\"]", html, re.IGNORECASE):
148
+ idx = html.rfind("</div>")
149
+ if idx != -1:
150
+ return html[:idx] + "\n" + chunk + "\n" + html[idx:]
151
+
152
+ # Fallback: before </body>
153
+ idx = html.lower().rfind("</body>")
154
+ if idx != -1:
155
+ return html[:idx] + "\n" + chunk + "\n" + html[idx:]
156
+
157
+ return html + "\n" + chunk
158
+
159
+
160
+ def _patch_section_by_id(html: str, sid: str, *, title: str | None, text: str | None) -> Tuple[str, bool]:
161
+ """
162
+ Patch one section's <h2> and the first intro <p> under it, but ONLY inside <section id="sid">.
163
+ If the intro <p> doesn't exist and text is provided, insert it after the first </h2>.
164
+ """
165
+ if not html or not sid:
166
+ return html, False
167
+
168
+ sid_esc = re.escape(sid)
169
+ pat = re.compile(_SECTION_BY_ID_RE.format(sid=sid_esc), re.IGNORECASE | re.DOTALL)
170
+ m = pat.search(html)
171
+ if not m:
172
+ return html, False
173
+
174
+ open_tag, inner, close_tag = m.group(1), m.group(2), m.group(3)
175
+ changed = False
176
+
177
+ # Patch H2 title
178
+ if title is not None:
179
+ title = title.strip()
180
+ if title:
181
+ h2_pat = re.compile(r'(<h2\b[^>]*>)(.*?)(</h2>)', re.IGNORECASE | re.DOTALL)
182
+ def _h2_repl(mm):
183
+ nonlocal changed
184
+ changed = True
185
+ return mm.group(1) + _esc_html(title) + mm.group(3)
186
+ inner2, n = h2_pat.subn(_h2_repl, inner, count=1)
187
+ inner = inner2 if n else inner
188
+
189
+ # Patch / insert intro paragraph right after first </h2>
190
+ if text is not None:
191
+ text = text.strip()
192
+ if text:
193
+ parts = re.split(r'(</h2>)', inner, maxsplit=1, flags=re.IGNORECASE)
194
+ if len(parts) == 3:
195
+ before = parts[0] + parts[1]
196
+ after = parts[2]
197
+
198
+ p_pat = re.compile(r'(<p\b[^>]*>)(.*?)(</p>)', re.IGNORECASE | re.DOTALL)
199
+ def _p_repl(mm):
200
+ nonlocal changed
201
+ changed = True
202
+ return mm.group(1) + _esc_html(text) + mm.group(3)
203
+
204
+ after2, n2 = p_pat.subn(_p_repl, after, count=1)
205
+
206
+ if n2 == 0:
207
+ # No intro paragraph existed, insert a safe one
208
+ changed = True
209
+ ins = f'<p class="reveal" style="margin-bottom:14px;">{_esc_html(text)}</p>'
210
+ inner = before + ins + after
211
+ else:
212
+ inner = before + after2
213
+
214
+ new_block = open_tag + inner + close_tag
215
+ new_html = html[:m.start()] + new_block + html[m.end():]
216
+ return new_html, changed
217
+
218
+
219
+ def _ensure_grid(sec, soup, cols: int):
220
+ wrap = sec.select_one(".wrap") or sec
221
+ grid = sec.select_one(".grid")
222
+ if grid is None:
223
+ grid = soup.new_tag("div")
224
+ grid["class"] = ["grid"]
225
+ wrap.append(grid)
226
+
227
+ cols = max(1, min(int(cols or 3), 5))
228
+ style = (grid.get("style") or "")
229
+ parts = [p.strip() for p in style.split(";") if p.strip() and not p.strip().lower().startswith("grid-template-columns")]
230
+ parts.append(f"grid-template-columns:repeat({cols}, minmax(0,1fr))")
231
+ grid["style"] = "; ".join(parts) + ";"
232
+ return grid
233
+
234
+
235
+ def _build_blank_section(soup, sid: str, stype: str, title: str, text: str, cols: int):
236
+ sec = soup.new_tag("section")
237
+ sec["id"] = sid
238
+ sec["class"] = ["sec"]
239
+ sec["data-section-type"] = (stype or "section")
240
+
241
+ wrap = soup.new_tag("div")
242
+ wrap["class"] = ["wrap"]
243
+ sec.append(wrap)
244
+
245
+ h2 = soup.new_tag("h2")
246
+ h2["class"] = ["reveal"]
247
+ h2.string = (title or "New section").strip()
248
+ wrap.append(h2)
249
+
250
+ if (text or "").strip():
251
+ p = soup.new_tag("p")
252
+ p["class"] = ["reveal"]
253
+ p["style"] = "margin-bottom:14px;"
254
+ p.string = text.strip()
255
+ wrap.append(p)
256
+
257
+ grid = soup.new_tag("div")
258
+ grid["class"] = ["grid"]
259
+ grid["style"] = f"grid-template-columns:repeat({max(1, min(int(cols or 3), 5))}, minmax(0,1fr));"
260
+ wrap.append(grid)
261
+
262
+ return sec
263
+
264
+
265
+ def _default_card_node(soup, it: Dict[str, Any]):
266
+ it_type = (it.get("type") or "card").strip().lower()
267
+ it_title = (it.get("title") or "").strip()
268
+ it_text = (it.get("text") or "").strip()
269
+ it_img = (it.get("imageUrl") or "").strip()
270
+
271
+ # Button item: render as a card containing an anchor
272
+ if it_type == "button":
273
+ href = _safe_href(it.get("href") or it.get("url") or it.get("link") or "#")
274
+ card = soup.new_tag("div")
275
+ card["class"] = ["card", "reveal"]
276
+
277
+ a = soup.new_tag("a")
278
+ a["class"] = ["btn", "primary", "smx-btn"]
279
+ a["href"] = href
280
+ a["data-smx"] = "button"
281
+ a.string = it_title or "Button"
282
+ card.append(a)
283
+
284
+ if it_text:
285
+ p = soup.new_tag("p")
286
+ p["style"] = "margin-top:8px;"
287
+ p.string = it_text
288
+ card.append(p)
289
+
290
+ return card
291
+
292
+ # Normal card
293
+ card = soup.new_tag("div")
294
+ card["class"] = ["card", "reveal"]
295
+
296
+ if it_img:
297
+ img = soup.new_tag("img")
298
+ img["loading"] = "lazy"
299
+ img["decoding"] = "async"
300
+ img["src"] = it_img
301
+ img["alt"] = it_title
302
+ card.append(img)
303
+
304
+ row = soup.new_tag("div")
305
+ row["style"] = "display:flex; gap:10px; align-items:center; margin-top:" + ("10px" if it_img else "0") + ";"
306
+ h3 = soup.new_tag("h3")
307
+ h3["style"] = "margin:0; font-size:1.05rem;"
308
+ h3.string = it_title
309
+ row.append(h3)
310
+ card.append(row)
311
+
312
+ p = soup.new_tag("p")
313
+ p["style"] = "margin-top:8px;"
314
+ p.string = it_text
315
+ card.append(p)
316
+
317
+ return card
318
+
319
+
320
+ def _patch_default_cards(sec, soup, items: List[Dict[str, Any]], cols: int) -> bool:
321
+ grid = _ensure_grid(sec, soup, cols)
322
+
323
+ # direct children represent items (cards OR actions)
324
+ children = [c for c in grid.find_all(True, recursive=False)]
325
+ changed = False
326
+ n = len(items)
327
+
328
+ def make_node(it: Dict[str, Any]):
329
+ if _is_button_item(it):
330
+ return _button_node(soup, it)
331
+ return _default_card_node(soup, it)
332
+
333
+ # sync count
334
+ if len(children) < n:
335
+ for i in range(len(children), n):
336
+ grid.append(make_node(items[i] if isinstance(items[i], dict) else {}))
337
+ changed = True
338
+ children = [c for c in grid.find_all(True, recursive=False)]
339
+
340
+ if len(children) > n:
341
+ for extra in children[n:]:
342
+ extra.decompose()
343
+ changed = True
344
+ children = [c for c in grid.find_all(True, recursive=False)]
345
+
346
+ # patch each
347
+ for i in range(n):
348
+ it = items[i] if isinstance(items[i], dict) else {}
349
+ node = children[i]
350
+
351
+ if _is_button_item(it):
352
+ # ensure button node
353
+ a = node.find("a", attrs={"data-smx": "button"}) or node.find("a")
354
+ if a is None:
355
+ node.replace_with(make_node(it))
356
+ changed = True
357
+ continue
358
+
359
+ label = (it.get("title") or "").strip() or "Read more"
360
+ href = _safe_href(it.get("href") or "#") or "#"
361
+
362
+ if a.get_text(strip=True) != label:
363
+ a.clear(); a.append(label); changed = True
364
+ if a.get("href") != href:
365
+ a["href"] = href; changed = True
366
+ continue
367
+
368
+ # normal card patch (existing behaviour)
369
+ it_title = (it.get("title") or "").strip()
370
+ it_text = (it.get("text") or "").strip()
371
+ it_img = (it.get("imageUrl") or "").strip()
372
+ it_href = _safe_href(it.get("href") or "")
373
+ cta_lbl = (it.get("ctaLabel") or "Read more").strip() or "Read more"
374
+
375
+ # image
376
+ img = node.find("img")
377
+ if it_img:
378
+ if img is None:
379
+ img = soup.new_tag("img")
380
+ img["loading"] = "lazy"
381
+ img["decoding"] = "async"
382
+ node.insert(0, img)
383
+ changed = True
384
+ if img.get("src") != it_img:
385
+ img["src"] = it_img; changed = True
386
+ if it_title and img.get("alt") != it_title:
387
+ img["alt"] = it_title; changed = True
388
+ else:
389
+ if img is not None:
390
+ img.decompose(); changed = True
391
+
392
+ # title
393
+ h = node.find(["h3", "h4"]) or node.find(["h2", "h3", "h4"])
394
+ if h and it_title and h.get_text(strip=True) != it_title:
395
+ h.clear(); h.append(it_title); changed = True
396
+
397
+ # text
398
+ p = node.find("p") or node.select_one(".mut")
399
+ if p and it_text and p.get_text(" ", strip=True) != it_text:
400
+ p.clear(); p.append(it_text); changed = True
401
+
402
+ # card CTA link (Read more) + alignment
403
+ align = (it.get("ctaAlign") or it.get("cta_align") or "").strip().lower() or "left"
404
+ if align == "centre":
405
+ align = "center"
406
+ if align not in ("left", "center", "right", "full"):
407
+ align = "left"
408
+
409
+ actions = node.find(attrs={"data-smx": "card-actions"})
410
+ # card CTA link (Read more) + alignment (Sprint 3)
411
+ cta_align = (it.get("ctaAlign") or it.get("cta_align") or "").strip().lower()
412
+ if cta_align == "centre":
413
+ cta_align = "center"
414
+ if cta_align not in ("left", "center", "right", "full"):
415
+ cta_align = ""
416
+
417
+ justify = {"left": "flex-start", "center": "center", "right": "flex-end"}.get(cta_align or "left", "flex-start")
418
+
419
+
420
+ actions = node.find("div", attrs={"data-smx": "card-actions"})
421
+ if actions is None:
422
+ actions = node.find("div", class_=lambda c: c and "smx-card-actions" in c.split())
423
+
424
+ a = None
425
+ if actions is not None:
426
+ a = actions.find("a", attrs={"data-smx": "card-cta"})
427
+ if a is None:
428
+ a = node.find("a", attrs={"data-smx": "card-cta"})
429
+
430
+ if it_href:
431
+ if actions is None:
432
+ actions = soup.new_tag("div")
433
+ actions["data-smx"] = "card-actions"
434
+ actions["class"] = ["smx-card-actions", f"align-{cta_align or 'left'}"]
435
+ node.append(actions)
436
+ changed = True
437
+
438
+ cls = list(actions.get("class") or [])
439
+ cls = [c for c in cls if not str(c).startswith("align-")]
440
+ if "smx-card-actions" not in cls:
441
+ cls.append("smx-card-actions")
442
+ cls.append(f"align-{cta_align or 'left'}")
443
+ if actions.get("class") != cls:
444
+ actions["class"] = cls; changed = True
445
+
446
+ desired_actions_style = f"display:flex; gap:10px; margin-top:12px; justify-content:{justify};"
447
+ if (actions.get("style") or "") != desired_actions_style:
448
+ actions["style"] = desired_actions_style
449
+ changed = True
450
+
451
+ if a is None:
452
+ a = soup.new_tag("a")
453
+ a["data-smx"] = "card-cta"
454
+ a["class"] = ["btn", "ghost", "smx-card-cta"]
455
+ actions.append(a)
456
+ changed = True
457
+ else:
458
+ if a.parent is not actions:
459
+ a.extract()
460
+ actions.append(a)
461
+ changed = True
462
+
463
+ # Sprint 3: full-width button
464
+ if cta_align == "full":
465
+ a_style = (a.get("style") or "")
466
+ desired_a_style = "width:100%; display:flex; justify-content:center; box-sizing:border-box; text-align:center;"
467
+ if a_style != desired_a_style:
468
+ a["style"] = desired_a_style
469
+ changed = True
470
+ else:
471
+ # remove full-width styling if switching away
472
+ if a.has_attr("style") and "width:100%" in (a.get("style") or ""):
473
+ del a["style"]
474
+ changed = True
475
+
476
+ if a.get("href") != it_href:
477
+ a["href"] = it_href; changed = True
478
+ if a.get_text(strip=True) != cta_lbl:
479
+ a.clear(); a.append(cta_lbl); changed = True
480
+ else:
481
+ if a is not None:
482
+ a.decompose(); changed = True
483
+ if actions is not None and actions.find(True) is None:
484
+ actions.decompose(); changed = True
485
+
486
+ changed = True
487
+ if actions is not None:
488
+ # remove wrapper if empty
489
+ if not actions.find(True):
490
+ actions.decompose()
491
+ changed = True
492
+
493
+ return changed
494
+
495
+
496
+ def _faq_detail_node(soup, it: Dict[str, Any]):
497
+ q = (it.get("title") or "").strip()
498
+ a = (it.get("text") or "").strip()
499
+ img_url = (it.get("imageUrl") or "").strip()
500
+
501
+ d = soup.new_tag("details")
502
+ d["class"] = ["reveal"]
503
+
504
+ s = soup.new_tag("summary")
505
+ s.string = q
506
+ d.append(s)
507
+
508
+ # optional image under the question
509
+ if img_url:
510
+ img = soup.new_tag("img")
511
+ img["loading"] = "lazy"
512
+ img["decoding"] = "async"
513
+ img["src"] = img_url
514
+ img["alt"] = q or "faq"
515
+ img["style"] = "width:100%;height:auto;border-radius:14px;margin-top:10px;display:block;"
516
+ img["data-smx"] = "faq-img"
517
+ d.append(img)
518
+
519
+ if a:
520
+ ans = soup.new_tag("div")
521
+ ans["class"] = ["mut"]
522
+ ans["style"] = "margin-top:8px;"
523
+ ans.string = a
524
+ d.append(ans)
525
+
526
+ return d
527
+
528
+
529
+ def _patch_faq(sec, soup, items: List[Dict[str, Any]]) -> bool:
530
+ """
531
+ Generator FAQ structure: <details><summary>Q</summary><div class="mut">A</div></details>
532
+ Supports adding/removing FAQ items.
533
+ """
534
+ changed = False
535
+ wrap = sec.select_one(".wrap") or sec
536
+
537
+ details = wrap.find_all("details", recursive=True)
538
+
539
+ # Adjust count
540
+ n = len(items)
541
+ if len(details) < n:
542
+ for i in range(len(details), n):
543
+ wrap.append(_faq_detail_node(soup, items[i] if isinstance(items[i], dict) else {}))
544
+ changed = True
545
+ details = wrap.find_all("details", recursive=True)
546
+
547
+ if len(details) > n:
548
+ for extra in details[n:]:
549
+ extra.decompose()
550
+ changed = True
551
+ details = wrap.find_all("details", recursive=True)
552
+
553
+ # Patch content
554
+ for i in range(min(len(details), n)):
555
+ it = items[i] if isinstance(items[i], dict) else {}
556
+ q = (it.get("title") or "").strip()
557
+ a = (it.get("text") or "").strip()
558
+
559
+ det = details[i]
560
+ summ = det.find("summary")
561
+ if summ and q and summ.get_text(strip=True) != q:
562
+ summ.clear()
563
+ summ.append(q)
564
+ changed = True
565
+
566
+ ans = det.select_one(".mut") or det.find("div")
567
+ if a:
568
+ if ans is None:
569
+ ans = soup.new_tag("div")
570
+ ans["class"] = ["mut"]
571
+ ans["style"] = "margin-top:8px;"
572
+ det.append(ans)
573
+ changed = True
574
+ if ans.get_text(" ", strip=True) != a:
575
+ ans.clear()
576
+ ans.append(a)
577
+ changed = True
578
+ else:
579
+ if ans is not None:
580
+ ans.decompose()
581
+ changed = True
582
+
583
+ img_url = (it.get("imageUrl") or "").strip()
584
+ img = det.find("img", attrs={"data-smx": "faq-img"}) or det.find("img")
585
+
586
+ if img_url:
587
+ if img is None:
588
+ img = soup.new_tag("img")
589
+ img["loading"] = "lazy"
590
+ img["decoding"] = "async"
591
+ img["style"] = "width:100%;height:auto;border-radius:14px;margin-top:10px;display:block;"
592
+ img["data-smx"] = "faq-img"
593
+ # insert right after summary
594
+ summ = det.find("summary")
595
+ if summ:
596
+ summ.insert_after(img)
597
+ else:
598
+ det.insert(0, img)
599
+ changed = True
600
+
601
+ if img.get("src") != img_url:
602
+ img["src"] = img_url
603
+ changed = True
604
+ if q and img.get("alt") != q:
605
+ img["alt"] = q
606
+ changed = True
607
+ else:
608
+ if img is not None:
609
+ img.decompose()
610
+ changed = True
611
+
612
+ return changed
613
+
614
+
615
+ def _testimonial_card_node(soup, it: Dict[str, Any]):
616
+ quote = (it.get("text") or "").strip()
617
+ who = (it.get("title") or "").strip()
618
+ img_url = (it.get("imageUrl") or "").strip()
619
+
620
+ card = soup.new_tag("div")
621
+ card["class"] = ["card", "reveal"]
622
+
623
+ # optional avatar/photo
624
+ if img_url:
625
+ img = soup.new_tag("img")
626
+ img["loading"] = "lazy"
627
+ img["decoding"] = "async"
628
+ img["src"] = img_url
629
+ img["alt"] = who or "testimonial"
630
+ img["style"] = "width:64px;height:64px;border-radius:999px;object-fit:cover;"
631
+ card.append(img)
632
+
633
+ qd = soup.new_tag("div")
634
+ qd["class"] = ["quote"]
635
+ qd.string = f"“{quote}”" if quote else ""
636
+ qd["style"] = "margin-top:10px;" if img_url else ""
637
+ card.append(qd)
638
+
639
+ if who:
640
+ wd = soup.new_tag("div")
641
+ wd["class"] = ["mut"]
642
+ wd["style"] = "margin-top:10px;font-weight:600;"
643
+ wd.string = who
644
+ card.append(wd)
645
+
646
+ return card
647
+
648
+
649
+ def _patch_testimonials(sec, soup, items: List[Dict[str, Any]], cols: int) -> bool:
650
+ """
651
+ Generator testimonials structure:
652
+ <div class="grid"> <div class="card"><div class="quote">…</div><div class="mut">Name</div></div> … </div>
653
+ Supports adding/removing testimonials.
654
+ """
655
+ changed = False
656
+ grid = sec.select_one(".grid")
657
+ if grid is None:
658
+ grid = _ensure_grid(sec, soup, max(1, min(cols, 3)))
659
+
660
+ cards = grid.select(":scope > .card")
661
+ n = len(items)
662
+
663
+ # Adjust count
664
+ if len(cards) < n:
665
+ for i in range(len(cards), n):
666
+ grid.append(_testimonial_card_node(soup, items[i] if isinstance(items[i], dict) else {}))
667
+ changed = True
668
+ cards = grid.select(":scope > .card")
669
+
670
+ if len(cards) > n:
671
+ for extra in cards[n:]:
672
+ extra.decompose()
673
+ changed = True
674
+ cards = grid.select(":scope > .card")
675
+
676
+ # Patch content
677
+ for i in range(min(len(cards), n)):
678
+ it = items[i] if isinstance(items[i], dict) else {}
679
+ quote = (it.get("text") or "").strip()
680
+ who = (it.get("title") or "").strip()
681
+
682
+ card = cards[i]
683
+ img_url = (it.get("imageUrl") or "").strip()
684
+ img = card.find("img")
685
+ if img_url:
686
+ if img is None:
687
+ img = soup.new_tag("img")
688
+ img["loading"] = "lazy"
689
+ img["decoding"] = "async"
690
+ img["style"] = "width:64px;height:64px;border-radius:999px;object-fit:cover;"
691
+ card.insert(0, img)
692
+ changed = True
693
+ if img.get("src") != img_url:
694
+ img["src"] = img_url
695
+ changed = True
696
+ if who and img.get("alt") != who:
697
+ img["alt"] = who
698
+ changed = True
699
+ else:
700
+ if img is not None:
701
+ img.decompose()
702
+ changed = True
703
+ qd = card.select_one(".quote") or card.find("div")
704
+ if qd and quote:
705
+ want = f"“{quote}”"
706
+ if qd.get_text(strip=True) != want:
707
+ qd.clear()
708
+ qd.append(want)
709
+ changed = True
710
+
711
+ wd = card.select_one(".mut")
712
+ if who:
713
+ if wd is None:
714
+ wd = soup.new_tag("div")
715
+ wd["class"] = ["mut"]
716
+ wd["style"] = "margin-top:10px;font-weight:600;"
717
+ card.append(wd)
718
+ changed = True
719
+ if wd.get_text(" ", strip=True) != who:
720
+ wd.clear()
721
+ wd.append(who)
722
+ changed = True
723
+ else:
724
+ if wd is not None:
725
+ wd.decompose()
726
+ changed = True
727
+
728
+ # Keep columns sensible
729
+ grid["style"] = f"grid-template-columns:repeat({max(1, min(cols, 3))}, minmax(0,1fr));"
730
+ return changed
731
+
732
+
733
+ def _patch_items_in_section_by_id(
734
+ html: str,
735
+ sid: str,
736
+ stype: str,
737
+ items: List[Dict[str, Any]],
738
+ cols: int
739
+ ) -> Tuple[str, bool]:
740
+ """
741
+ Dispatcher: patch items according to widget type, within <section id="sid"> only.
742
+ """
743
+ if not html or not sid:
744
+ return html, False
745
+
746
+ soup = BeautifulSoup(html, "html.parser")
747
+ sec = soup.find("section", id=sid)
748
+ if sec is None:
749
+ return html, False
750
+
751
+ st = (stype or "").lower().strip()
752
+
753
+ if st == "faq":
754
+ changed = _patch_faq(sec, soup, items)
755
+ return str(soup), changed
756
+
757
+ if st == "testimonials":
758
+ changed = _patch_testimonials(sec, soup, items, cols=cols)
759
+ return str(soup), changed
760
+
761
+ # default cards grid: features/gallery/cta/richtext/anything else that uses cards
762
+ changed = _patch_default_cards(sec, soup, items, cols=cols)
763
+ return str(soup), changed
764
+
765
+
766
+ def _patch_hero(html: str, hero_section: Dict[str, Any]) -> Tuple[str, bool]:
767
+ """
768
+ Patch hero bg image + <h1> + lead paragraph inside the hero section.
769
+ Handles both:
770
+ - <div class="hero-bg" style="background-image:...">
771
+ - <img ...> used as hero media
772
+ """
773
+ if not html or not isinstance(hero_section, dict):
774
+ return html, False
775
+
776
+ sid = (hero_section.get("id") or "").strip()
777
+ title = (hero_section.get("title") or "").strip()
778
+ text = (hero_section.get("text") or "").strip()
779
+
780
+ img_url = (hero_section.get("imageUrl") or "").strip()
781
+ items = hero_section.get("items") if isinstance(hero_section.get("items"), list) else []
782
+ if not img_url and items and isinstance(items[0], dict):
783
+ img_url = (items[0].get("imageUrl") or "").strip()
784
+
785
+ soup = BeautifulSoup(html, "html.parser")
786
+
787
+ # Locate hero section
788
+ hero_tag = None
789
+ if sid:
790
+ hero_tag = soup.find("section", id=sid)
791
+
792
+ if hero_tag is None:
793
+ # fallback: first section with class containing 'hero'
794
+ for sec in soup.find_all("section"):
795
+ cls = " ".join(sec.get("class") or [])
796
+ if "hero" in cls.split():
797
+ hero_tag = sec
798
+ break
799
+
800
+ if hero_tag is None:
801
+ return html, False
802
+
803
+ changed = False
804
+
805
+ # Patch hero image
806
+ if img_url:
807
+ # 1) background div
808
+ bg = hero_tag.select_one(".hero-bg")
809
+ if bg is not None:
810
+ style = bg.get("style") or ""
811
+ parts = [p.strip() for p in style.split(";") if p.strip() and not p.strip().lower().startswith("background-image")]
812
+ parts.append(f'background-image:url("{img_url}")')
813
+ bg["style"] = "; ".join(parts) + ";"
814
+ changed = True
815
+ else:
816
+ # 2) hero img fallback
817
+ im = hero_tag.find("img")
818
+ if im is not None:
819
+ im["src"] = img_url
820
+ # also update srcset if present (best-effort)
821
+ if im.has_attr("srcset"):
822
+ im["srcset"] = img_url
823
+ changed = True
824
+
825
+ # Patch hero H1
826
+ if title:
827
+ h1 = hero_tag.find("h1")
828
+ if h1 is not None:
829
+ h1.clear()
830
+ h1.append(title)
831
+ changed = True
832
+
833
+ # Patch lead paragraph (class 'lead' preferred; fallback: first long <p> inside hero)
834
+ if text:
835
+ lead = hero_tag.select_one("p.lead")
836
+ if lead is None:
837
+ # fallback: first paragraph with enough text
838
+ for p in hero_tag.find_all("p"):
839
+ t = p.get_text(" ", strip=True)
840
+ if len(t) >= 15:
841
+ lead = p
842
+ break
843
+ if lead is not None:
844
+ lead.clear()
845
+ lead.append(text)
846
+ changed = True
847
+
848
+ # Patch hero buttons (btnRow) if heroCta fields exist in layout
849
+ has_cta_fields = any(k in hero_section for k in ("heroCta1Label", "heroCta1Href", "heroCta2Label", "heroCta2Href"))
850
+ if has_cta_fields:
851
+ row = hero_tag.select_one(".btnRow")
852
+
853
+ # If missing, create it inside the hero panel (best-effort)
854
+ if row is None:
855
+ panel = hero_tag.select_one(".hero-panel") or hero_tag
856
+ row = soup.new_tag("div")
857
+ row["class"] = ["btnRow"]
858
+ panel.append(row)
859
+
860
+ # Rebuild buttons from layout (blank/unsafe href => remove button)
861
+ row.clear()
862
+
863
+ def _add_btn(label_key: str, href_key: str, cta_no: int):
864
+ nonlocal changed
865
+ label = (hero_section.get(label_key) or "").strip() or "Button"
866
+ href_raw = hero_section.get(href_key, "")
867
+ href = _safe_href(str(href_raw))
868
+ if not href:
869
+ return
870
+ a = soup.new_tag("a")
871
+ a["class"] = ["btn"]
872
+ a["data-smx"] = "hero-cta"
873
+ a["data-cta"] = str(cta_no)
874
+ a["href"] = href
875
+ a.string = label
876
+ row.append(a)
877
+ changed = True
878
+
879
+ _add_btn("heroCta1Label", "heroCta1Href", 1)
880
+ _add_btn("heroCta2Label", "heroCta2Href", 2)
881
+
882
+ # If user removed both, remove the row entirely
883
+ if not row.find("a"):
884
+ row.decompose()
885
+ changed = True
886
+
887
+ # Strip legacy Admin/Edit hero buttons from older generated pages
888
+ for a in list(hero_tag.find_all("a")):
889
+ href = (a.get("href") or "").strip()
890
+ if href == "/admin" or href.startswith("/admin/edit/"):
891
+ parent = a.parent
892
+ a.decompose()
893
+ changed = True
894
+
895
+ # If the parent btnRow becomes empty, remove it
896
+ if parent is not None and getattr(parent, "name", None):
897
+ if "btnRow" in (parent.get("class") or []) and not parent.find("a"):
898
+ parent.decompose()
899
+ changed = True
900
+
901
+ return str(soup), changed
902
+
903
+
904
+ def _fallback_section_fragment(soup, s: Dict[str, Any]) -> Any:
905
+ sid = (s.get("id") or "").strip() or "sec_new"
906
+ title = (s.get("title") or "").strip()
907
+ text = (s.get("text") or "").strip()
908
+
909
+ sec = soup.new_tag("section")
910
+ sec["id"] = sid
911
+ sec["class"] = ["sec"]
912
+
913
+ wrap = soup.new_tag("div")
914
+ wrap["class"] = ["wrap"]
915
+ sec.append(wrap)
916
+
917
+ h2 = soup.new_tag("h2")
918
+ h2["class"] = ["reveal"]
919
+ h2.string = title or "New section"
920
+ wrap.append(h2)
921
+
922
+ if text:
923
+ p = soup.new_tag("p")
924
+ p["class"] = ["reveal"]
925
+ p["style"] = "margin-bottom:14px;"
926
+ p.string = text
927
+ wrap.append(p)
928
+
929
+ # grid placeholder (items patcher will fill it)
930
+ grid = soup.new_tag("div")
931
+ grid["class"] = ["grid"]
932
+ grid["style"] = "grid-template-columns:repeat(3, minmax(0,1fr));"
933
+ wrap.append(grid)
934
+
935
+ return sec
936
+
937
+
938
+ def ensure_sections_exist(existing_html: str, layout: Dict[str, Any], *, page_slug: Optional[str] = None) -> Tuple[str, int]:
939
+ """
940
+ If the layout contains sections that are missing from existing_html, insert them.
941
+ We try to copy the section HTML from compile_layout_to_html(layout).
942
+ Fallback: create a minimal section fragment.
943
+ """
944
+ if not existing_html or not isinstance(layout, dict):
945
+ return existing_html, 0
946
+
947
+ sections = layout.get("sections") if isinstance(layout.get("sections"), list) else []
948
+ want_ids = []
949
+ for s in sections:
950
+ if not isinstance(s, dict):
951
+ continue
952
+ sid = (s.get("id") or "").strip()
953
+ if sid:
954
+ want_ids.append(sid)
955
+
956
+ if not want_ids:
957
+ return existing_html, 0
958
+
959
+ soup = BeautifulSoup(existing_html, "html.parser")
960
+
961
+ # Find where sections live (parent of first existing <section>, else <main>, else <body>)
962
+ first_sec = soup.find("section")
963
+ container = first_sec.parent if first_sec and first_sec.parent else (soup.find("main") or soup.body or soup)
964
+
965
+ existing = {sec.get("id") for sec in soup.find_all("section") if sec.get("id")}
966
+
967
+ # Build a source soup from compiler output (best-effort)
968
+ src_soup = None
969
+ try:
970
+ from syntaxmatrix.page_builder_generation import compile_layout_to_html
971
+ compiled = compile_layout_to_html(layout, page_slug=page_slug or (layout.get("page") or "page"))
972
+ src_soup = BeautifulSoup(compiled, "html.parser")
973
+ except Exception:
974
+ src_soup = None
975
+
976
+ inserted = 0
977
+ prev_tag = None
978
+
979
+ # Insert missing sections in layout order (after the last seen section)
980
+ for s in sections:
981
+ if not isinstance(s, dict):
982
+ continue
983
+ sid = (s.get("id") or "").strip()
984
+ if not sid:
985
+ continue
986
+
987
+ already = soup.find("section", id=sid)
988
+ if already is not None:
989
+ prev_tag = already
990
+ continue
991
+
992
+ # Get section HTML from compiled output
993
+ new_sec = src_soup.find("section", id=sid) if src_soup else None
994
+ if new_sec is None:
995
+ new_sec = _fallback_section_fragment(soup, s)
996
+ else:
997
+ frag = BeautifulSoup(str(new_sec), "html.parser").find("section")
998
+ new_sec = frag if frag is not None else _fallback_section_fragment(soup, s)
999
+
1000
+ if prev_tag is not None:
1001
+ prev_tag.insert_after(new_sec)
1002
+ else:
1003
+ container.append(new_sec)
1004
+
1005
+ prev_tag = new_sec
1006
+ inserted += 1
1007
+ existing.add(sid)
1008
+
1009
+ return str(soup), inserted
1010
+
1011
+
1012
+ def _css_safe_hex(c: str) -> str:
1013
+ c = (c or "").strip()
1014
+ m = re.fullmatch(r"#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})", c)
1015
+ if not m:
1016
+ return ""
1017
+ hx = m.group(0).lower()
1018
+ if len(hx) == 4:
1019
+ hx = "#" + "".join([ch * 2 for ch in hx[1:]])
1020
+ return hx
1021
+
1022
+
1023
+ def _hex_to_rgba(hx: str, a: float) -> str:
1024
+ hx = _css_safe_hex(hx)
1025
+ if not hx:
1026
+ return ""
1027
+ r = int(hx[1:3], 16)
1028
+ g = int(hx[3:5], 16)
1029
+ b = int(hx[5:7], 16)
1030
+ a = float(a)
1031
+ if a < 0:
1032
+ a = 0.0
1033
+ if a > 1:
1034
+ a = 1.0
1035
+ return f"rgba({r},{g},{b},{a:.3f})"
1036
+
1037
+
1038
+ def _css_safe_font(ff: str) -> str:
1039
+ ff = (ff or "").strip()
1040
+ if not ff:
1041
+ return ""
1042
+ # refuse anything that can terminate / inject CSS
1043
+ bad = ["{", "}", ";", "<", ">", "\n", "\r"]
1044
+ if any(b in ff for b in bad):
1045
+ return ""
1046
+ return ff
1047
+
1048
+
1049
+ def _safe_slug(s: str) -> str:
1050
+ s = (s or "").strip().lower()
1051
+ s = re.sub(r"[^a-z0-9\-]+", "-", s).strip("-")
1052
+ return s or "page"
1053
+
1054
+
1055
+ def _build_theme_style(layout: dict, *, page_slug: str | None = None) -> str:
1056
+ theme = layout.get("theme") if isinstance(layout.get("theme"), dict) else {}
1057
+ if not theme:
1058
+ return ""
1059
+
1060
+ font_body = _css_safe_font(theme.get("fontBody") or theme.get("bodyFont") or theme.get("font_body") or "")
1061
+ font_head = _css_safe_font(theme.get("fontHeading") or theme.get("headingFont") or theme.get("font_heading") or "")
1062
+
1063
+ accent = _css_safe_hex(theme.get("accent") or "")
1064
+ fg = _css_safe_hex(theme.get("fg") or "")
1065
+ mut = _css_safe_hex(theme.get("mut") or "")
1066
+ bg = _css_safe_hex(theme.get("bg") or "")
1067
+
1068
+ # If user sets text but not muted (or vice-versa), make it visibly change.
1069
+ if fg and not mut:
1070
+ mut = fg
1071
+ if mut and not fg:
1072
+ fg = mut
1073
+
1074
+ if not any([font_body, font_head, accent, fg, mut, bg]):
1075
+ return ""
1076
+
1077
+ slug = _safe_slug(page_slug or "")
1078
+ root_id = f"smx-page-{slug}"
1079
+
1080
+ # Apply to the page id AND as a fallback to any SMX page wrapper.
1081
+ root_sel = f'#{root_id}, div[id^="smx-page-"]'
1082
+
1083
+ lines: List[str] = []
1084
+ lines.append(f"{root_sel}{{")
1085
+
1086
+ if fg:
1087
+ lines.append(f" --fg:{fg} !important;")
1088
+ lines.append(" color:var(--fg) !important;")
1089
+ if mut:
1090
+ lines.append(f" --mut:{mut} !important;")
1091
+ if bg:
1092
+ lines.append(f" --bg:{bg} !important;")
1093
+ lines.append(" background:var(--bg) !important;")
1094
+ if font_body:
1095
+ lines.append(f" font-family:{font_body} !important;")
1096
+
1097
+ if accent:
1098
+ lines.append(f" --accent:{accent} !important;")
1099
+ soft = _hex_to_rgba(accent, 0.12)
1100
+ if soft:
1101
+ lines.append(f" --accentSoft:{soft} !important;")
1102
+
1103
+ lines.append("}")
1104
+
1105
+ # Make body text visibly change (your compiled templates often force p to --mut)
1106
+ if fg:
1107
+ lines.append(f'#{root_id} p, div[id^="smx-page-"] p{{ color:var(--fg) !important; }}')
1108
+ if mut:
1109
+ # preserve muted styling for explicit muted elements
1110
+ lines.append(f'#{root_id} .mut, #{root_id} .kicker, div[id^="smx-page-"] .mut, div[id^="smx-page-"] .kicker{{ color:var(--mut) !important; }}')
1111
+
1112
+ if font_head:
1113
+ lines.append(
1114
+ f'#{root_id} h1, #{root_id} h2, #{root_id} h3, '
1115
+ f'div[id^="smx-page-"] h1, div[id^="smx-page-"] h2, div[id^="smx-page-"] h3'
1116
+ f'{{ font-family:{font_head} !important; }}'
1117
+ )
1118
+
1119
+ if accent:
1120
+ lines.append(f'#{root_id} a, div[id^="smx-page-"] a{{ color:var(--accent) !important; }}')
1121
+ lines.append(f'#{root_id} .btn, div[id^="smx-page-"] .btn{{ background:var(--accentSoft, rgba(99,102,241,.12)) !important; }}')
1122
+
1123
+ css = "\n".join(lines)
1124
+ return f'<style id="smx-theme" data-smx="theme">\n{css}\n</style>'
1125
+
1126
+
1127
+ def _patch_theme(existing_html: str, layout: dict, *, page_slug: str | None = None) -> tuple[str, bool]:
1128
+ if not existing_html or not isinstance(layout, dict):
1129
+ return existing_html, False
1130
+
1131
+ # 1) Remove ALL existing theme blocks (you currently have many duplicates)
1132
+ pat_all = re.compile(r'<style\b[^>]*\bid="smx-theme"[^>]*>.*?</style>\s*', re.IGNORECASE | re.DOTALL)
1133
+ cleaned = pat_all.sub("", existing_html)
1134
+
1135
+ # 2) Build the new theme block
1136
+ new_style = _build_theme_style(layout, page_slug=page_slug)
1137
+
1138
+ # If theme now empty, just return the cleaned HTML
1139
+ if not new_style:
1140
+ return cleaned, (cleaned != existing_html)
1141
+
1142
+ # 3) Insert in a stable place: inside the page wrapper, right after the base <style> if present
1143
+ slug = _safe_slug(page_slug or "")
1144
+ root_id = f"smx-page-{slug}"
1145
+
1146
+ # Find the wrapper open tag
1147
+ m = re.search(rf'<div\b[^>]*\bid=["\']{re.escape(root_id)}["\'][^>]*>', cleaned, re.IGNORECASE)
1148
+ if m:
1149
+ start = m.end()
1150
+ # Insert after the first </style> following the wrapper (this keeps it near the existing page CSS)
1151
+ lower = cleaned.lower()
1152
+ k = lower.find("</style>", start)
1153
+ if k != -1:
1154
+ insert_at = k + len("</style>")
1155
+ out = cleaned[:insert_at] + "\n" + new_style + cleaned[insert_at:]
1156
+ return out, True
1157
+
1158
+ # Otherwise insert immediately after wrapper open
1159
+ out = cleaned[:start] + "\n" + new_style + cleaned[start:]
1160
+ return out, True
1161
+
1162
+ # Fallback: insert before </head> if it exists
1163
+ lower = cleaned.lower()
1164
+ kh = lower.find("</head>")
1165
+ if kh != -1:
1166
+ out = cleaned[:kh] + new_style + "\n" + cleaned[kh:]
1167
+ return out, True
1168
+
1169
+ # Final fallback: prepend
1170
+ return new_style + "\n" + cleaned, True
1171
+
1172
+
1173
+ def _style_to_dict(style: str) -> dict:
1174
+ """
1175
+ Parse inline style="a:b; c:d" -> {"a":"b", "c":"d"}
1176
+ """
1177
+ out = {}
1178
+ for part in (style or "").split(";"):
1179
+ part = part.strip()
1180
+ if not part or ":" not in part:
1181
+ continue
1182
+ k, v = part.split(":", 1)
1183
+ k = k.strip().lower()
1184
+ v = v.strip()
1185
+ if k:
1186
+ out[k] = v
1187
+ return out
1188
+
1189
+
1190
+ def _dict_to_style(d: dict) -> str:
1191
+ """
1192
+ {"a":"b","c":"d"} -> "a:b; c:d;"
1193
+ """
1194
+ if not d:
1195
+ return ""
1196
+ return "; ".join([f"{k}:{v}" for k, v in d.items() if k and v]) + ";"
1197
+
1198
+
1199
+ def _merge_inline_style(existing: str, set_kv: dict | None = None, remove_keys: list[str] | None = None) -> str:
1200
+ d = _style_to_dict(existing or "")
1201
+ for k in (remove_keys or []):
1202
+ d.pop((k or "").strip().lower(), None)
1203
+ for k, v in (set_kv or {}).items():
1204
+ kk = (k or "").strip().lower()
1205
+ vv = (v or "").strip()
1206
+ if not kk:
1207
+ continue
1208
+ if not vv:
1209
+ d.pop(kk, None)
1210
+ else:
1211
+ d[kk] = vv
1212
+ return _dict_to_style(d)
1213
+
1214
+
1215
+ def _sec_style_parse(section: dict) -> dict:
1216
+ """
1217
+ Normalise supported section style inputs from layout JSON.
1218
+
1219
+ Expected layout shape (example):
1220
+ section["style"] = {
1221
+ "bg": "#ffffff",
1222
+ "pad": "compact" | "normal" | "spacious",
1223
+ "align": "left" | "center" | "right"
1224
+ }
1225
+ """
1226
+ raw = section.get("style")
1227
+ if not isinstance(raw, dict):
1228
+ raw = {}
1229
+
1230
+ bg = _css_safe_hex(raw.get("bg") or raw.get("background") or "")
1231
+ pad = (raw.get("pad") or raw.get("padding") or "").strip().lower()
1232
+ align = (raw.get("align") or raw.get("textAlign") or raw.get("text_align") or "").strip().lower()
1233
+
1234
+ if pad not in {"compact", "normal", "spacious"}:
1235
+ pad = ""
1236
+ if align not in {"left", "center", "right"}:
1237
+ align = ""
1238
+
1239
+ return {"bg": bg, "pad": pad, "align": align}
1240
+
1241
+
1242
+ def _sec_style_dump(style: dict) -> tuple[dict, dict]:
1243
+ """
1244
+ Returns:
1245
+ (section_style_kv, wrap_style_kv)
1246
+ """
1247
+ sec_kv = {}
1248
+ wrap_kv = {}
1249
+
1250
+ bg = (style or {}).get("bg") or ""
1251
+ pad = (style or {}).get("pad") or ""
1252
+ align = (style or {}).get("align") or ""
1253
+
1254
+ # Background on <section>
1255
+ if bg:
1256
+ sec_kv["background"] = bg
1257
+
1258
+ # Padding override on <section> (base CSS is .sec{ padding:56px 0; })
1259
+ # normal = no override (remove any previous override)
1260
+ if pad == "compact":
1261
+ sec_kv["padding"] = "36px 0"
1262
+ elif pad == "spacious":
1263
+ sec_kv["padding"] = "84px 0"
1264
+
1265
+ # Text alignment: safer to apply to the section's inner ".wrap"
1266
+ if align in {"left", "center", "right"} and align != "left":
1267
+ wrap_kv["text-align"] = align
1268
+
1269
+ return sec_kv, wrap_kv
1270
+
1271
+
1272
+ def _patch_section_styles(existing_html: str, layout: dict) -> tuple[str, bool]:
1273
+ """
1274
+ Applies per-section styles to:
1275
+ <section id="{sid}"> ... <div class="wrap"> ... </div> </section>
1276
+
1277
+ - Sets/removes inline background + padding on section
1278
+ - Sets/removes text-align on the section's first .wrap
1279
+ """
1280
+ if not existing_html or not isinstance(layout, dict):
1281
+ return existing_html, False
1282
+
1283
+ sections = layout.get("sections") if isinstance(layout.get("sections"), list) else []
1284
+ if not sections:
1285
+ return existing_html, False
1286
+
1287
+ soup = BeautifulSoup(existing_html, "html.parser")
1288
+ changed = False
1289
+
1290
+ for s in sections:
1291
+ if not isinstance(s, dict):
1292
+ continue
1293
+
1294
+ sid = (s.get("id") or "").strip()
1295
+ if not sid:
1296
+ continue
1297
+
1298
+ # Find the section node by id
1299
+ sec = soup.find(id=sid)
1300
+ if not sec:
1301
+ continue
1302
+
1303
+ # We only want the <section> element; if the id is on something else, try to find a <section id="sid">
1304
+ if sec.name != "section":
1305
+ sec2 = soup.find("section", attrs={"id": sid})
1306
+ if not sec2:
1307
+ continue
1308
+ sec = sec2
1309
+
1310
+ style = _sec_style_parse(s)
1311
+ sec_kv, wrap_kv = _sec_style_dump(style)
1312
+
1313
+ # Always remove keys if they were previously set, so "reset to default" works
1314
+ sec_remove = ["background", "padding"]
1315
+ wrap_remove = ["text-align"]
1316
+
1317
+ # Apply section inline styles
1318
+ before = sec.get("style") or ""
1319
+ after = _merge_inline_style(before, set_kv=sec_kv, remove_keys=sec_remove)
1320
+ if after != before:
1321
+ if after:
1322
+ sec["style"] = after
1323
+ else:
1324
+ sec.attrs.pop("style", None)
1325
+ changed = True
1326
+
1327
+ # Apply wrap alignment (first .wrap inside the section)
1328
+ wrap = sec.find("div", class_="wrap")
1329
+ if wrap:
1330
+ w_before = wrap.get("style") or ""
1331
+ w_after = _merge_inline_style(w_before, set_kv=wrap_kv, remove_keys=wrap_remove)
1332
+ if w_after != w_before:
1333
+ if w_after:
1334
+ wrap["style"] = w_after
1335
+ else:
1336
+ wrap.attrs.pop("style", None)
1337
+ changed = True
1338
+
1339
+ out = str(soup)
1340
+ return out, changed
1341
+
1342
+
1343
+ def patch_page_publish(existing_html: str, layout: Dict[str, Any], page_slug: Optional[str] = None) -> Tuple[str, Dict[str, int]]:
1344
+ """
1345
+ Patch-only publish:
1346
+ - hero bg + hero title + hero lead
1347
+ - each non-hero section <h2> + intro <p>, matched by section id
1348
+ """
1349
+ stats = {"hero": 0, "sections": 0, "skipped": 0}
1350
+
1351
+ if not existing_html or not isinstance(layout, dict):
1352
+ return existing_html, stats
1353
+
1354
+ sections = layout.get("sections") if isinstance(layout.get("sections"), list) else []
1355
+
1356
+ # Without this, patch-only publish can only *update* existing HTML, not remove stale blocks.
1357
+ existing_html, removed = _remove_deleted_sections(existing_html, layout, page_slug=page_slug)
1358
+ if removed:
1359
+ stats["removed_sections"] = removed
1360
+
1361
+ # NEW: insert any missing widget sections so patching can update them
1362
+ existing_html, inserted = ensure_sections_exist(existing_html, layout)
1363
+ if inserted:
1364
+ stats["inserted_sections"] = inserted
1365
+
1366
+ existing_html, theme_changed = _patch_theme(existing_html, layout, page_slug=page_slug)
1367
+ if theme_changed:
1368
+ stats["theme"] = 1
1369
+
1370
+ # hero
1371
+ hero = next((s for s in sections if isinstance(s, dict) and (s.get("type") or "").lower() == "hero"), None)
1372
+ if hero:
1373
+ existing_html, hero_changed = _patch_hero(existing_html, hero)
1374
+ if hero_changed:
1375
+ stats["hero"] = 1
1376
+
1377
+ # other sections by id
1378
+ for s in sections:
1379
+ if not isinstance(s, dict):
1380
+ continue
1381
+ if (s.get("type") or "").lower() == "hero":
1382
+ continue
1383
+
1384
+ sid = (s.get("id") or "").strip()
1385
+ if not sid:
1386
+ stats["skipped"] += 1
1387
+ continue
1388
+
1389
+ title = s.get("title")
1390
+ text = s.get("text")
1391
+
1392
+ existing_html, ok = _patch_section_by_id(existing_html, sid, title=title, text=text)
1393
+ if ok:
1394
+ stats["sections"] += 1
1395
+
1396
+ items = s.get("items") if isinstance(s.get("items"), list) else []
1397
+ stype = (s.get("type") or "").lower()
1398
+ try:
1399
+ cols = int(s.get("cols") or 3)
1400
+ except Exception:
1401
+ cols = 3
1402
+ cols = max(1, min(5, cols))
1403
+
1404
+ # Patch items even if empty (empty means "remove extras" for that widget)
1405
+ existing_html, ok_items = _patch_items_in_section_by_id(existing_html, sid, stype, items, cols)
1406
+ if ok_items:
1407
+ stats[f"items_{stype or 'section'}"] = stats.get(f"items_{stype or 'section'}", 0) + 1
1408
+
1409
+ # Sprint 4: apply per-section styles (bg/padding/align)
1410
+ existing_html, sec_style_changed = _patch_section_styles(existing_html, layout)
1411
+ if sec_style_changed:
1412
+ stats["section_styles"] = stats.get("section_styles", 0) + 1
1413
+
1414
+
1415
+ return existing_html, stats
1416
+
1417
+
1418
+ def _remove_deleted_sections(existing_html: str, layout: Dict[str, Any], *, page_slug: Optional[str] = None) -> Tuple[str, int]:
1419
+ """Remove <section> blocks that exist in HTML but no longer exist in the saved layout.
1420
+
1421
+ Patch-only publish updates content in-place. Without this step, deleting a section in the
1422
+ builder will not remove the old <section> from the previously generated HTML, so it will
1423
+ still show on the live page.
1424
+ """
1425
+ if not existing_html or not isinstance(layout, dict):
1426
+ return existing_html, 0
1427
+
1428
+ sections = layout.get("sections") if isinstance(layout.get("sections"), list) else []
1429
+ keep_ids = set()
1430
+ for s in sections:
1431
+ if isinstance(s, dict):
1432
+ sid = (s.get("id") or "").strip()
1433
+ if sid:
1434
+ keep_ids.add(sid)
1435
+
1436
+ soup = BeautifulSoup(existing_html, "html.parser")
1437
+
1438
+ # Restrict removals to the page wrapper if we can find it.
1439
+ root = None
1440
+ if page_slug:
1441
+ raw = str(page_slug).strip()
1442
+ safe = re.sub(r"[^a-z0-9\-]+", "-", raw.lower()).strip("-")
1443
+ for rid in (f"smx-page-{raw}", f"smx-page-{safe}"):
1444
+ root = soup.find("div", id=rid)
1445
+ if root:
1446
+ break
1447
+
1448
+ if not root:
1449
+ root = soup.find("div", id=re.compile(r"^smx-page-", re.IGNORECASE))
1450
+
1451
+ # Fallback: if wrapper not found, operate on body (still safe because we only remove
1452
+ # builder-style sections).
1453
+ if not root:
1454
+ root = soup.body or soup
1455
+
1456
+ removed = 0
1457
+ for sec in list(root.find_all("section")):
1458
+ sid = (sec.get("id") or "").strip()
1459
+ if not sid:
1460
+ continue
1461
+
1462
+ # Only touch builder sections.
1463
+ is_builder_section = sid.startswith("sec_") or sec.has_attr("data-section-type")
1464
+ if not is_builder_section:
1465
+ continue
1466
+
1467
+ if sid not in keep_ids:
1468
+ sec.decompose()
1469
+ removed += 1
1470
+
1471
+ return str(soup), removed