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,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("&", "&")
|
|
82
|
+
.replace("<", "<")
|
|
83
|
+
.replace(">", ">")
|
|
84
|
+
.replace('"', """)
|
|
85
|
+
.replace("'", "'")
|
|
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
|