syntaxmatrix 2.6.4.3__py3-none-any.whl → 3.0.0__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 +6 -4
- syntaxmatrix/agentic/agents.py +195 -15
- syntaxmatrix/agentic/agents_orchestrer.py +16 -10
- syntaxmatrix/client_docs.py +237 -0
- syntaxmatrix/commentary.py +96 -25
- syntaxmatrix/core.py +156 -54
- syntaxmatrix/dataset_preprocessing.py +2 -2
- syntaxmatrix/db.py +60 -0
- syntaxmatrix/db_backends/__init__.py +1 -0
- syntaxmatrix/db_backends/postgres_backend.py +14 -0
- syntaxmatrix/db_backends/sqlite_backend.py +258 -0
- syntaxmatrix/db_contract.py +71 -0
- syntaxmatrix/kernel_manager.py +174 -150
- syntaxmatrix/page_builder_generation.py +654 -50
- syntaxmatrix/page_layout_contract.py +25 -3
- syntaxmatrix/page_patch_publish.py +368 -15
- syntaxmatrix/plugins/__init__.py +0 -0
- syntaxmatrix/plugins/plugin_manager.py +114 -0
- syntaxmatrix/premium/__init__.py +18 -0
- syntaxmatrix/premium/catalogue/__init__.py +121 -0
- syntaxmatrix/premium/gate.py +119 -0
- syntaxmatrix/premium/state.py +507 -0
- syntaxmatrix/premium/verify.py +222 -0
- syntaxmatrix/profiles.py +1 -1
- syntaxmatrix/routes.py +9782 -8004
- syntaxmatrix/settings/model_map.py +50 -65
- syntaxmatrix/settings/prompts.py +1435 -380
- syntaxmatrix/settings/string_navbar.py +4 -4
- syntaxmatrix/static/icons/bot_icon.png +0 -0
- syntaxmatrix/static/icons/bot_icon2.png +0 -0
- syntaxmatrix/templates/admin_billing.html +408 -0
- syntaxmatrix/templates/admin_branding.html +65 -2
- syntaxmatrix/templates/admin_features.html +54 -0
- syntaxmatrix/templates/dashboard.html +285 -8
- syntaxmatrix/templates/edit_page.html +199 -18
- syntaxmatrix/themes.py +17 -17
- syntaxmatrix/workspace_db.py +0 -23
- syntaxmatrix-3.0.0.dist-info/METADATA +219 -0
- {syntaxmatrix-2.6.4.3.dist-info → syntaxmatrix-3.0.0.dist-info}/RECORD +42 -30
- {syntaxmatrix-2.6.4.3.dist-info → syntaxmatrix-3.0.0.dist-info}/WHEEL +1 -1
- syntaxmatrix/settings/default.yaml +0 -13
- syntaxmatrix-2.6.4.3.dist-info/METADATA +0 -539
- syntaxmatrix-2.6.4.3.dist-info/licenses/LICENSE.txt +0 -21
- /syntaxmatrix/static/icons/{logo3.png → logo2.png} +0 -0
- {syntaxmatrix-2.6.4.3.dist-info → syntaxmatrix-3.0.0.dist-info}/top_level.txt +0 -0
|
@@ -150,10 +150,32 @@ def _s(x: Any) -> str:
|
|
|
150
150
|
return x.strip() if isinstance(x, str) else ""
|
|
151
151
|
|
|
152
152
|
def _clean_ws(s: str) -> str:
|
|
153
|
-
|
|
153
|
+
"""Single-line clean (titles, ids, etc.)."""
|
|
154
|
+
s = (s or "").strip()
|
|
154
155
|
s = re.sub(r"\s+", " ", s).strip()
|
|
155
156
|
return s
|
|
156
157
|
|
|
158
|
+
|
|
159
|
+
def _clean_text_block(s: str) -> str:
|
|
160
|
+
"""
|
|
161
|
+
Preserve new lines for body text.
|
|
162
|
+
- Normalise line endings
|
|
163
|
+
- Collapse repeated spaces/tabs *within a line*
|
|
164
|
+
- Keep paragraph breaks
|
|
165
|
+
"""
|
|
166
|
+
s = (s or "")
|
|
167
|
+
s = s.replace("\r\n", "\n").replace("\r", "\n")
|
|
168
|
+
|
|
169
|
+
lines = []
|
|
170
|
+
for ln in s.split("\n"):
|
|
171
|
+
ln = re.sub(r"[ \t]{2,}", " ", ln).rstrip()
|
|
172
|
+
lines.append(ln)
|
|
173
|
+
|
|
174
|
+
out = "\n".join(lines).strip()
|
|
175
|
+
out = re.sub(r"\n{3,}", "\n\n", out) # cap huge blank gaps
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
157
179
|
def _has_dangerous(s: str) -> bool:
|
|
158
180
|
return bool(DANGEROUS_RE.search(s or ""))
|
|
159
181
|
|
|
@@ -284,7 +306,7 @@ def normalise_layout(
|
|
|
284
306
|
|
|
285
307
|
# Strings
|
|
286
308
|
s2["title"] = _clean_ws(_s(s2.get("title")))
|
|
287
|
-
s2["text"] =
|
|
309
|
+
s2["text"] = _clean_text_block(_s(s2.get("text")))
|
|
288
310
|
|
|
289
311
|
# Items
|
|
290
312
|
items = s2.get("items")
|
|
@@ -298,7 +320,7 @@ def normalise_layout(
|
|
|
298
320
|
it2["id"] = _safe_id(_s(it2.get("id"))) or f"item_{sid}_{j+1}"
|
|
299
321
|
it2["type"] = _safe_id(_s(it2.get("type"))) or "card"
|
|
300
322
|
it2["title"] = _clean_ws(_s(it2.get("title")))
|
|
301
|
-
it2["text"] =
|
|
323
|
+
it2["text"] = _clean_text_block(_s(it2.get("text")))
|
|
302
324
|
if "imageUrl" in it2:
|
|
303
325
|
it2["imageUrl"] = _clean_ws(_s(it2.get("imageUrl")))
|
|
304
326
|
items2.append(it2)
|
|
@@ -4,6 +4,76 @@ from typing import Any, Dict, Tuple, List, Optional
|
|
|
4
4
|
from bs4 import BeautifulSoup
|
|
5
5
|
|
|
6
6
|
|
|
7
|
+
_RICH_DROP_TAGS = {"script", "style", "iframe", "object", "embed", "link", "meta", "base"}
|
|
8
|
+
|
|
9
|
+
def _safe_text_align(v: str) -> str:
|
|
10
|
+
v = (v or "").strip().lower()
|
|
11
|
+
if v == "centre":
|
|
12
|
+
v = "center"
|
|
13
|
+
return v if v in ("left", "center", "right", "justify") else ""
|
|
14
|
+
|
|
15
|
+
def _sanitize_rich_fragment(html: str) -> str:
|
|
16
|
+
"""
|
|
17
|
+
Best-effort sanitiser for rich text coming from the page editor.
|
|
18
|
+
Removes scripts/events and javascript/data URLs.
|
|
19
|
+
"""
|
|
20
|
+
html = (html or "").strip()
|
|
21
|
+
if not html:
|
|
22
|
+
return ""
|
|
23
|
+
|
|
24
|
+
frag = BeautifulSoup(html, "html.parser")
|
|
25
|
+
|
|
26
|
+
# Drop dangerous container tags entirely
|
|
27
|
+
for t in frag.find_all(list(_RICH_DROP_TAGS)):
|
|
28
|
+
t.decompose()
|
|
29
|
+
|
|
30
|
+
# Scrub attributes
|
|
31
|
+
for tag in frag.find_all(True):
|
|
32
|
+
# remove inline on* handlers
|
|
33
|
+
for k in list(tag.attrs.keys()):
|
|
34
|
+
if k.lower().startswith("on"):
|
|
35
|
+
del tag.attrs[k]
|
|
36
|
+
|
|
37
|
+
# block javascript:/data: URLs
|
|
38
|
+
for url_attr in ("href", "src"):
|
|
39
|
+
if url_attr in tag.attrs:
|
|
40
|
+
v = str(tag.attrs.get(url_attr) or "").strip().lower()
|
|
41
|
+
if v.startswith("javascript:") or v.startswith("data:"):
|
|
42
|
+
del tag.attrs[url_attr]
|
|
43
|
+
|
|
44
|
+
# scrub risky style content
|
|
45
|
+
if "style" in tag.attrs:
|
|
46
|
+
st = str(tag.attrs.get("style") or "")
|
|
47
|
+
low = st.lower()
|
|
48
|
+
if "expression(" in low or "javascript:" in low:
|
|
49
|
+
del tag.attrs["style"]
|
|
50
|
+
else:
|
|
51
|
+
# remove url(...) to avoid surprises
|
|
52
|
+
st = re.sub(r"url\s*\([^)]*\)", "", st, flags=re.IGNORECASE)
|
|
53
|
+
tag.attrs["style"] = st
|
|
54
|
+
|
|
55
|
+
return "".join(str(c) for c in frag.contents).strip()
|
|
56
|
+
|
|
57
|
+
def _set_inner_html(tag, html: str):
|
|
58
|
+
tag.clear()
|
|
59
|
+
frag = BeautifulSoup(html or "", "html.parser")
|
|
60
|
+
for child in list(frag.contents):
|
|
61
|
+
tag.append(child)
|
|
62
|
+
|
|
63
|
+
def _set_text_with_breaks(soup, tag, text: str):
|
|
64
|
+
"""
|
|
65
|
+
Put plain text into an element but keep user newlines visible.
|
|
66
|
+
"""
|
|
67
|
+
tag.clear()
|
|
68
|
+
text = text or ""
|
|
69
|
+
lines = text.splitlines()
|
|
70
|
+
for i, ln in enumerate(lines):
|
|
71
|
+
if i > 0:
|
|
72
|
+
tag.append(soup.new_tag("br"))
|
|
73
|
+
if ln:
|
|
74
|
+
tag.append(ln)
|
|
75
|
+
|
|
76
|
+
|
|
7
77
|
_SECTION_BY_ID_RE = r'(<section\b[^>]*\bid="{sid}"[^>]*>)(.*?)(</section>)'
|
|
8
78
|
_SECTION_OPEN_RE = re.compile(r"<section\b[^>]*\bid=['\"]([^'\"]+)['\"][^>]*>", re.IGNORECASE)
|
|
9
79
|
|
|
@@ -309,10 +379,24 @@ def _default_card_node(soup, it: Dict[str, Any]):
|
|
|
309
379
|
row.append(h3)
|
|
310
380
|
card.append(row)
|
|
311
381
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
382
|
+
it_text_html = (it.get("textHtml") or "").strip()
|
|
383
|
+
|
|
384
|
+
# Body: prefer rich HTML if provided, otherwise plain text
|
|
385
|
+
if it_text_html:
|
|
386
|
+
body = soup.new_tag("div")
|
|
387
|
+
body["class"] = ["smx-rich"]
|
|
388
|
+
body["data-smx"] = "card-body"
|
|
389
|
+
body["style"] = "margin-top:8px;"
|
|
390
|
+
safe = _sanitize_rich_fragment(it_text_html)
|
|
391
|
+
if safe:
|
|
392
|
+
_set_inner_html(body, safe)
|
|
393
|
+
card.append(body)
|
|
394
|
+
else:
|
|
395
|
+
p = soup.new_tag("p")
|
|
396
|
+
p["data-smx"] = "card-text"
|
|
397
|
+
p["style"] = "margin-top:8px;"
|
|
398
|
+
_set_text_with_breaks(soup, p, it_text)
|
|
399
|
+
card.append(p)
|
|
316
400
|
|
|
317
401
|
return card
|
|
318
402
|
|
|
@@ -363,11 +447,39 @@ def _patch_default_cards(sec, soup, items: List[Dict[str, Any]], cols: int) -> b
|
|
|
363
447
|
a.clear(); a.append(label); changed = True
|
|
364
448
|
if a.get("href") != href:
|
|
365
449
|
a["href"] = href; changed = True
|
|
450
|
+
|
|
451
|
+
# Open exported HTML results in a new tab (client docs assets)
|
|
452
|
+
href = str(it_href or "").strip()
|
|
453
|
+
auto_newtab = href.startswith("/docs/") and href.lower().endswith(".html")
|
|
454
|
+
|
|
455
|
+
open_new = bool(
|
|
456
|
+
auto_newtab
|
|
457
|
+
or it.get("openInNewTab")
|
|
458
|
+
or it.get("newTab")
|
|
459
|
+
or (str(it.get("target") or "").strip().lower() == "_blank")
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
if open_new:
|
|
463
|
+
if a.get("target") != "_blank":
|
|
464
|
+
a["target"] = "_blank"; changed = True
|
|
465
|
+
# keep rel consistent
|
|
466
|
+
if a.get("rel") != "noopener":
|
|
467
|
+
a["rel"] = "noopener"; changed = True
|
|
468
|
+
else:
|
|
469
|
+
if a.has_attr("target"):
|
|
470
|
+
del a["target"]; changed = True
|
|
471
|
+
if a.has_attr("rel"):
|
|
472
|
+
del a["rel"]; changed = True
|
|
473
|
+
|
|
366
474
|
continue
|
|
367
475
|
|
|
368
|
-
# normal card patch (existing behaviour)
|
|
476
|
+
# normal card patch (existing behaviour)
|
|
369
477
|
it_title = (it.get("title") or "").strip()
|
|
370
478
|
it_text = (it.get("text") or "").strip()
|
|
479
|
+
it_text_html = (it.get("textHtml") or "").strip()
|
|
480
|
+
title_align = _safe_text_align(it.get("titleAlign") or it.get("title_align") or "")
|
|
481
|
+
body_align = _safe_text_align(it.get("bodyAlign") or it.get("body_align") or "")
|
|
482
|
+
|
|
371
483
|
it_img = (it.get("imageUrl") or "").strip()
|
|
372
484
|
it_href = _safe_href(it.get("href") or "")
|
|
373
485
|
cta_lbl = (it.get("ctaLabel") or "Read more").strip() or "Read more"
|
|
@@ -391,15 +503,65 @@ def _patch_default_cards(sec, soup, items: List[Dict[str, Any]], cols: int) -> b
|
|
|
391
503
|
|
|
392
504
|
# title
|
|
393
505
|
h = node.find(["h3", "h4"]) or node.find(["h2", "h3", "h4"])
|
|
394
|
-
if h and it_title
|
|
395
|
-
h.
|
|
506
|
+
if h and it_title:
|
|
507
|
+
cur_title = h.get_text("\n", strip=False).strip()
|
|
508
|
+
if cur_title != it_title:
|
|
509
|
+
_set_text_with_breaks(soup, h, it_title)
|
|
510
|
+
changed = True
|
|
511
|
+
if title_align:
|
|
512
|
+
h["style"] = _merge_inline_style(h.get("style") or "", {"text-align": title_align})
|
|
513
|
+
|
|
514
|
+
# body (prefer rich)
|
|
515
|
+
body = (
|
|
516
|
+
node.find(attrs={"data-smx": "card-body"})
|
|
517
|
+
or node.find("div", class_=lambda c: c and "smx-rich" in str(c))
|
|
518
|
+
or node.find("p")
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
if it_text_html:
|
|
522
|
+
safe = _sanitize_rich_fragment(it_text_html)
|
|
523
|
+
|
|
524
|
+
# ensure we have a div container for rich HTML
|
|
525
|
+
if body is None or body.name != "div":
|
|
526
|
+
new_body = soup.new_tag("div")
|
|
527
|
+
new_body["class"] = ["smx-rich"]
|
|
528
|
+
new_body["data-smx"] = "card-body"
|
|
529
|
+
new_body["style"] = "margin-top:8px;"
|
|
530
|
+
if body is not None:
|
|
531
|
+
body.replace_with(new_body)
|
|
532
|
+
else:
|
|
533
|
+
node.append(new_body)
|
|
534
|
+
body = new_body
|
|
535
|
+
changed = True
|
|
536
|
+
|
|
537
|
+
cur_html = "".join(str(c) for c in body.contents).strip()
|
|
538
|
+
if safe and cur_html != safe:
|
|
539
|
+
_set_inner_html(body, safe)
|
|
540
|
+
changed = True
|
|
541
|
+
|
|
542
|
+
else:
|
|
543
|
+
# plain text path (keep newlines visible)
|
|
544
|
+
if body is None or body.name != "p":
|
|
545
|
+
new_p = soup.new_tag("p")
|
|
546
|
+
new_p["data-smx"] = "card-text"
|
|
547
|
+
new_p["style"] = "margin-top:8px;"
|
|
548
|
+
if body is not None:
|
|
549
|
+
body.replace_with(new_p)
|
|
550
|
+
else:
|
|
551
|
+
node.append(new_p)
|
|
552
|
+
body = new_p
|
|
553
|
+
changed = True
|
|
554
|
+
|
|
555
|
+
cur_text = body.get_text("\n", strip=False).strip()
|
|
556
|
+
if it_text and cur_text != it_text:
|
|
557
|
+
_set_text_with_breaks(soup, body, it_text)
|
|
558
|
+
changed = True
|
|
396
559
|
|
|
397
|
-
#
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
p.clear(); p.append(it_text); changed = True
|
|
560
|
+
# body alignment
|
|
561
|
+
if body_align and body is not None:
|
|
562
|
+
body["style"] = _merge_inline_style(body.get("style") or "", {"text-align": body_align})
|
|
401
563
|
|
|
402
|
-
|
|
564
|
+
# card CTA link (Read more) + alignment
|
|
403
565
|
align = (it.get("ctaAlign") or it.get("cta_align") or "").strip().lower() or "left"
|
|
404
566
|
if align == "centre":
|
|
405
567
|
align = "center"
|
|
@@ -729,6 +891,104 @@ def _patch_testimonials(sec, soup, items: List[Dict[str, Any]], cols: int) -> bo
|
|
|
729
891
|
grid["style"] = f"grid-template-columns:repeat({max(1, min(cols, 3))}, minmax(0,1fr));"
|
|
730
892
|
return changed
|
|
731
893
|
|
|
894
|
+
def _patch_gallery_items(sec, soup, items: List[Dict[str, Any]]) -> bool:
|
|
895
|
+
"""
|
|
896
|
+
Patch a gallery section so image items render as true gallery tiles (figure.gimg)
|
|
897
|
+
compatible with compile_layout_to_html/gallery_js.
|
|
898
|
+
"""
|
|
899
|
+
grid = sec.select_one(".grid")
|
|
900
|
+
if grid is None:
|
|
901
|
+
wrap = sec.select_one(".wrap") or sec
|
|
902
|
+
grid = soup.new_tag("div")
|
|
903
|
+
grid["class"] = ["grid"]
|
|
904
|
+
wrap.append(grid)
|
|
905
|
+
|
|
906
|
+
changed = False
|
|
907
|
+
|
|
908
|
+
# Only manage direct children of grid
|
|
909
|
+
children = [c for c in grid.find_all(True, recursive=False)]
|
|
910
|
+
|
|
911
|
+
def make_node(it: Dict[str, Any]):
|
|
912
|
+
it = it if isinstance(it, dict) else {}
|
|
913
|
+
it_type = (it.get("type") or "card").strip().lower()
|
|
914
|
+
|
|
915
|
+
title_plain = (it.get("title") or "").strip()
|
|
916
|
+
text_plain = (it.get("text") or "").strip()
|
|
917
|
+
|
|
918
|
+
img_full = (it.get("imageUrl") or "").strip()
|
|
919
|
+
img_thumb = (it.get("thumbUrl") or it.get("thumb_url") or "").strip()
|
|
920
|
+
src = img_thumb or img_full
|
|
921
|
+
|
|
922
|
+
# TRUE gallery image tile
|
|
923
|
+
if it_type == "image" and src:
|
|
924
|
+
fig = soup.new_tag("figure")
|
|
925
|
+
fig["class"] = ["gimg", "reveal"]
|
|
926
|
+
fig["data-item-type"] = "image"
|
|
927
|
+
fig["data-full"] = img_full or src
|
|
928
|
+
fig["data-title"] = title_plain
|
|
929
|
+
fig["data-caption"] = text_plain
|
|
930
|
+
|
|
931
|
+
im = soup.new_tag("img")
|
|
932
|
+
im["loading"] = "lazy"
|
|
933
|
+
im["decoding"] = "async"
|
|
934
|
+
im["src"] = src
|
|
935
|
+
im["data-full"] = img_full or src
|
|
936
|
+
im["alt"] = title_plain or "image"
|
|
937
|
+
fig.append(im)
|
|
938
|
+
|
|
939
|
+
if title_plain:
|
|
940
|
+
cap = soup.new_tag("figcaption")
|
|
941
|
+
cap.string = title_plain
|
|
942
|
+
fig.append(cap)
|
|
943
|
+
|
|
944
|
+
return fig
|
|
945
|
+
|
|
946
|
+
# Otherwise render as a normal card (content card inside rail)
|
|
947
|
+
node = _default_card_node(soup, it)
|
|
948
|
+
node["data-item-type"] = it_type or "card"
|
|
949
|
+
return node
|
|
950
|
+
|
|
951
|
+
n = len(items)
|
|
952
|
+
|
|
953
|
+
# Sync child count
|
|
954
|
+
if len(children) < n:
|
|
955
|
+
for i in range(len(children), n):
|
|
956
|
+
grid.append(make_node(items[i]))
|
|
957
|
+
changed = True
|
|
958
|
+
children = [c for c in grid.find_all(True, recursive=False)]
|
|
959
|
+
|
|
960
|
+
if len(children) > n:
|
|
961
|
+
for extra in children[n:]:
|
|
962
|
+
extra.decompose()
|
|
963
|
+
changed = True
|
|
964
|
+
children = [c for c in grid.find_all(True, recursive=False)]
|
|
965
|
+
|
|
966
|
+
# Patch / replace nodes if type mismatches
|
|
967
|
+
for i in range(n):
|
|
968
|
+
it = items[i] if isinstance(items[i], dict) else {}
|
|
969
|
+
want_type = (it.get("type") or "card").strip().lower()
|
|
970
|
+
|
|
971
|
+
node = children[i]
|
|
972
|
+
is_fig = (node.name == "figure" and "gimg" in (node.get("class") or []))
|
|
973
|
+
want_fig = (want_type == "image" and bool((it.get("thumbUrl") or it.get("thumb_url") or it.get("imageUrl") or "").strip()))
|
|
974
|
+
|
|
975
|
+
if want_fig and not is_fig:
|
|
976
|
+
node.replace_with(make_node(it))
|
|
977
|
+
changed = True
|
|
978
|
+
continue
|
|
979
|
+
|
|
980
|
+
if (not want_fig) and is_fig:
|
|
981
|
+
node.replace_with(make_node(it))
|
|
982
|
+
changed = True
|
|
983
|
+
continue
|
|
984
|
+
|
|
985
|
+
# For simplicity and correctness, refresh the node content by replacement
|
|
986
|
+
# (keeps behaviour consistent with compile_layout_to_html)
|
|
987
|
+
node.replace_with(make_node(it))
|
|
988
|
+
changed = True
|
|
989
|
+
|
|
990
|
+
return changed
|
|
991
|
+
|
|
732
992
|
|
|
733
993
|
def _patch_items_in_section_by_id(
|
|
734
994
|
html: str,
|
|
@@ -758,11 +1018,14 @@ def _patch_items_in_section_by_id(
|
|
|
758
1018
|
changed = _patch_testimonials(sec, soup, items, cols=cols)
|
|
759
1019
|
return str(soup), changed
|
|
760
1020
|
|
|
1021
|
+
if st == "gallery":
|
|
1022
|
+
changed = _patch_gallery_items(sec, soup, items)
|
|
1023
|
+
return str(soup), changed
|
|
1024
|
+
|
|
761
1025
|
# default cards grid: features/gallery/cta/richtext/anything else that uses cards
|
|
762
1026
|
changed = _patch_default_cards(sec, soup, items, cols=cols)
|
|
763
1027
|
return str(soup), changed
|
|
764
1028
|
|
|
765
|
-
|
|
766
1029
|
def _patch_hero(html: str, hero_section: Dict[str, Any]) -> Tuple[str, bool]:
|
|
767
1030
|
"""
|
|
768
1031
|
Patch hero bg image + <h1> + lead paragraph inside the hero section.
|
|
@@ -1350,6 +1613,84 @@ def _patch_section_styles(existing_html: str, layout: dict) -> tuple[str, bool]:
|
|
|
1350
1613
|
out = str(soup)
|
|
1351
1614
|
return out, changed
|
|
1352
1615
|
|
|
1616
|
+
def _ensure_gallery_assets(layout: dict) -> tuple[dict, bool]:
|
|
1617
|
+
"""
|
|
1618
|
+
Normalise gallery sections so that image additions behave like true gallery tiles.
|
|
1619
|
+
|
|
1620
|
+
Rule:
|
|
1621
|
+
- In a section with type == "gallery", any item that has an image (imageUrl/thumbUrl/imgQuery)
|
|
1622
|
+
but is typed as "card" (or missing type) is converted to type="image" by default.
|
|
1623
|
+
- We preserve title/text, but keep them short (gallery tiles should be image-first).
|
|
1624
|
+
"""
|
|
1625
|
+
changed = False
|
|
1626
|
+
if not isinstance(layout, dict):
|
|
1627
|
+
return layout, changed
|
|
1628
|
+
|
|
1629
|
+
secs = layout.get("sections")
|
|
1630
|
+
if not isinstance(secs, list):
|
|
1631
|
+
return layout, changed
|
|
1632
|
+
|
|
1633
|
+
def _has_img(it: dict) -> bool:
|
|
1634
|
+
return bool(
|
|
1635
|
+
(it.get("imageUrl") or "").strip()
|
|
1636
|
+
or (it.get("thumbUrl") or it.get("thumb_url") or "").strip()
|
|
1637
|
+
or (it.get("imgQuery") or "").strip()
|
|
1638
|
+
or bool(it.get("needsImage"))
|
|
1639
|
+
)
|
|
1640
|
+
|
|
1641
|
+
def _meaningful_text(s: str) -> bool:
|
|
1642
|
+
s = (s or "").strip()
|
|
1643
|
+
return len(s) >= 20 # heuristic: longer text suggests a content card
|
|
1644
|
+
|
|
1645
|
+
for sec in secs:
|
|
1646
|
+
if not isinstance(sec, dict):
|
|
1647
|
+
continue
|
|
1648
|
+
if str(sec.get("type") or "").strip().lower() != "gallery":
|
|
1649
|
+
continue
|
|
1650
|
+
|
|
1651
|
+
items = sec.get("items")
|
|
1652
|
+
if not isinstance(items, list):
|
|
1653
|
+
continue
|
|
1654
|
+
|
|
1655
|
+
for it in items:
|
|
1656
|
+
if not isinstance(it, dict):
|
|
1657
|
+
continue
|
|
1658
|
+
|
|
1659
|
+
it_type = str(it.get("type") or "").strip().lower()
|
|
1660
|
+
if it_type == "image":
|
|
1661
|
+
continue
|
|
1662
|
+
|
|
1663
|
+
if not _has_img(it):
|
|
1664
|
+
continue
|
|
1665
|
+
|
|
1666
|
+
# If it looks like a content card (long text), leave it as card.
|
|
1667
|
+
# Otherwise treat it as a gallery image tile.
|
|
1668
|
+
title = (it.get("title") or "").strip()
|
|
1669
|
+
text = (it.get("text") or "").strip()
|
|
1670
|
+
|
|
1671
|
+
if _meaningful_text(text):
|
|
1672
|
+
# keep as card: someone likely wants a case-study style card inside the gallery rail
|
|
1673
|
+
if not it_type:
|
|
1674
|
+
it["type"] = "card"
|
|
1675
|
+
changed = True
|
|
1676
|
+
continue
|
|
1677
|
+
|
|
1678
|
+
it["type"] = "image"
|
|
1679
|
+
changed = True
|
|
1680
|
+
|
|
1681
|
+
# Optional: keep gallery captions concise (avoid feature-card paragraphs)
|
|
1682
|
+
if len(text) > 110:
|
|
1683
|
+
it["text"] = text[:107].rstrip() + "…"
|
|
1684
|
+
|
|
1685
|
+
if len(title) > 60:
|
|
1686
|
+
it["title"] = title[:57].rstrip() + "…"
|
|
1687
|
+
|
|
1688
|
+
# Ensure fields exist (helps downstream)
|
|
1689
|
+
it.setdefault("thumbUrl", it.get("thumb_url", "") or "")
|
|
1690
|
+
it.setdefault("imageUrl", it.get("imageUrl", "") or "")
|
|
1691
|
+
|
|
1692
|
+
return layout, changed
|
|
1693
|
+
|
|
1353
1694
|
|
|
1354
1695
|
def patch_page_publish(existing_html: str, layout: Dict[str, Any], page_slug: Optional[str] = None) -> Tuple[str, Dict[str, int]]:
|
|
1355
1696
|
"""
|
|
@@ -1360,6 +1701,11 @@ def patch_page_publish(existing_html: str, layout: Dict[str, Any], page_slug: Op
|
|
|
1360
1701
|
stats = {"hero": 0, "sections": 0, "skipped": 0}
|
|
1361
1702
|
|
|
1362
1703
|
if not existing_html or not isinstance(layout, dict):
|
|
1704
|
+
# Inject Gallery rail + lightbox behaviour ONLY if this page has a gallery section
|
|
1705
|
+
existing_html, gallery_injected = _ensure_gallery_assets(existing_html)
|
|
1706
|
+
if gallery_injected:
|
|
1707
|
+
stats["gallery_assets_injected"] = 1
|
|
1708
|
+
|
|
1363
1709
|
return existing_html, stats
|
|
1364
1710
|
|
|
1365
1711
|
sections = layout.get("sections") if isinstance(layout.get("sections"), list) else []
|
|
@@ -1470,8 +1816,15 @@ def _remove_deleted_sections(existing_html: str, layout: Dict[str, Any], *, page
|
|
|
1470
1816
|
if not sid:
|
|
1471
1817
|
continue
|
|
1472
1818
|
|
|
1473
|
-
|
|
1474
|
-
|
|
1819
|
+
cls = [c for c in (sec.get("class") or []) if isinstance(c, str)]
|
|
1820
|
+
|
|
1821
|
+
# Treat generator sections as builder-owned even if the ID is custom.
|
|
1822
|
+
is_builder_section = (
|
|
1823
|
+
sid.startswith("sec_")
|
|
1824
|
+
or sec.has_attr("data-section-type")
|
|
1825
|
+
or ("sec" in cls)
|
|
1826
|
+
or ("hero" in cls)
|
|
1827
|
+
)
|
|
1475
1828
|
if not is_builder_section:
|
|
1476
1829
|
continue
|
|
1477
1830
|
|
|
File without changes
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _safe_json_loads(raw: str, *, default: Any) -> Any:
|
|
11
|
+
try:
|
|
12
|
+
return json.loads(raw)
|
|
13
|
+
except Exception:
|
|
14
|
+
return default
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class PluginSpec:
|
|
19
|
+
"""A single plugin to load.
|
|
20
|
+
|
|
21
|
+
module: python module path (e.g. 'syntaxmatrix_premium_cloud_db')
|
|
22
|
+
name: entitlement key (e.g. 'cloud_db')
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
module: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PluginManager:
|
|
30
|
+
"""Loads optional plugins (typically premium) in a controlled, safe way."""
|
|
31
|
+
|
|
32
|
+
ENV_PLUGINS = "SMX_PREMIUM_PLUGINS" # JSON list of {name,module}
|
|
33
|
+
DB_PLUGINS_KEY = "premium.plugins" # JSON list of {name,module}
|
|
34
|
+
|
|
35
|
+
def __init__(self, smx: object, *, gate: Optional[object] = None, db: Optional[object] = None):
|
|
36
|
+
self._smx = smx
|
|
37
|
+
self._gate = gate
|
|
38
|
+
self._db = db
|
|
39
|
+
self.loaded: Dict[str, str] = {} # name -> module
|
|
40
|
+
self.errors: List[str] = []
|
|
41
|
+
|
|
42
|
+
def _specs_from_env(self) -> List[PluginSpec]:
|
|
43
|
+
raw = os.environ.get(self.ENV_PLUGINS, "").strip()
|
|
44
|
+
if not raw:
|
|
45
|
+
return []
|
|
46
|
+
data = _safe_json_loads(raw, default=[])
|
|
47
|
+
return self._coerce_specs(data)
|
|
48
|
+
|
|
49
|
+
def _specs_from_db(self) -> List[PluginSpec]:
|
|
50
|
+
if not self._db:
|
|
51
|
+
return []
|
|
52
|
+
get_setting = getattr(self._db, "get_setting", None)
|
|
53
|
+
if not callable(get_setting):
|
|
54
|
+
return []
|
|
55
|
+
raw = get_setting(self.DB_PLUGINS_KEY, "[]")
|
|
56
|
+
data = _safe_json_loads(str(raw or "[]"), default=[])
|
|
57
|
+
return self._coerce_specs(data)
|
|
58
|
+
|
|
59
|
+
def _coerce_specs(self, data: Any) -> List[PluginSpec]:
|
|
60
|
+
out: List[PluginSpec] = []
|
|
61
|
+
if isinstance(data, list):
|
|
62
|
+
for row in data:
|
|
63
|
+
if not isinstance(row, dict):
|
|
64
|
+
continue
|
|
65
|
+
name = str(row.get("name") or "").strip()
|
|
66
|
+
module = str(row.get("module") or "").strip()
|
|
67
|
+
if name and module:
|
|
68
|
+
out.append(PluginSpec(name=name, module=module))
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
def _entitled(self, name: str) -> bool:
|
|
72
|
+
if not self._gate:
|
|
73
|
+
return True # if no gate configured, don't block
|
|
74
|
+
enabled = getattr(self._gate, "enabled", None)
|
|
75
|
+
if not callable(enabled):
|
|
76
|
+
return True
|
|
77
|
+
try:
|
|
78
|
+
return bool(enabled(name))
|
|
79
|
+
except Exception:
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
def load_all(self) -> Tuple[Dict[str, str], List[str]]:
|
|
83
|
+
"""Load all configured plugins. Returns (loaded, errors)."""
|
|
84
|
+
specs = self._specs_from_env()
|
|
85
|
+
if not specs:
|
|
86
|
+
specs = self._specs_from_db()
|
|
87
|
+
|
|
88
|
+
for spec in specs:
|
|
89
|
+
if not self._entitled(spec.name):
|
|
90
|
+
continue
|
|
91
|
+
if spec.name in self.loaded:
|
|
92
|
+
continue
|
|
93
|
+
self._load_one(spec)
|
|
94
|
+
|
|
95
|
+
return self.loaded, self.errors
|
|
96
|
+
|
|
97
|
+
def _load_one(self, spec: PluginSpec) -> None:
|
|
98
|
+
try:
|
|
99
|
+
mod = importlib.import_module(spec.module)
|
|
100
|
+
except Exception as e:
|
|
101
|
+
self.errors.append(f"{spec.name}: import failed: {e}")
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
# Plugin contract: module exposes register(smx) -> None
|
|
105
|
+
reg = getattr(mod, "register", None)
|
|
106
|
+
if not callable(reg):
|
|
107
|
+
self.errors.append(f"{spec.name}: missing register(smx) function")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
reg(self._smx)
|
|
112
|
+
self.loaded[spec.name] = spec.module
|
|
113
|
+
except Exception as e:
|
|
114
|
+
self.errors.append(f"{spec.name}: register() failed: {e}")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Premium support.
|
|
2
|
+
|
|
3
|
+
This package contains runtime plumbing for premium features (entitlements +
|
|
4
|
+
plugin loading).
|
|
5
|
+
|
|
6
|
+
Key idea:
|
|
7
|
+
- FeatureGate reads a single entitlement dict from env/db/licence.json
|
|
8
|
+
- ensure_premium_state() resolves trial + plan entitlements and writes them
|
|
9
|
+
into app_settings so the rest of the app can safely gate routes + navbar.
|
|
10
|
+
|
|
11
|
+
Premium implementations (e.g. non-SQLite DB backends) should live in separate,
|
|
12
|
+
private distributions.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .gate import FeatureGate
|
|
16
|
+
from .state import ensure_premium_state, PremiumState
|
|
17
|
+
|
|
18
|
+
__all__ = ["FeatureGate", "ensure_premium_state", "PremiumState"]
|