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.
Files changed (45) hide show
  1. syntaxmatrix/__init__.py +6 -4
  2. syntaxmatrix/agentic/agents.py +195 -15
  3. syntaxmatrix/agentic/agents_orchestrer.py +16 -10
  4. syntaxmatrix/client_docs.py +237 -0
  5. syntaxmatrix/commentary.py +96 -25
  6. syntaxmatrix/core.py +156 -54
  7. syntaxmatrix/dataset_preprocessing.py +2 -2
  8. syntaxmatrix/db.py +60 -0
  9. syntaxmatrix/db_backends/__init__.py +1 -0
  10. syntaxmatrix/db_backends/postgres_backend.py +14 -0
  11. syntaxmatrix/db_backends/sqlite_backend.py +258 -0
  12. syntaxmatrix/db_contract.py +71 -0
  13. syntaxmatrix/kernel_manager.py +174 -150
  14. syntaxmatrix/page_builder_generation.py +654 -50
  15. syntaxmatrix/page_layout_contract.py +25 -3
  16. syntaxmatrix/page_patch_publish.py +368 -15
  17. syntaxmatrix/plugins/__init__.py +0 -0
  18. syntaxmatrix/plugins/plugin_manager.py +114 -0
  19. syntaxmatrix/premium/__init__.py +18 -0
  20. syntaxmatrix/premium/catalogue/__init__.py +121 -0
  21. syntaxmatrix/premium/gate.py +119 -0
  22. syntaxmatrix/premium/state.py +507 -0
  23. syntaxmatrix/premium/verify.py +222 -0
  24. syntaxmatrix/profiles.py +1 -1
  25. syntaxmatrix/routes.py +9782 -8004
  26. syntaxmatrix/settings/model_map.py +50 -65
  27. syntaxmatrix/settings/prompts.py +1435 -380
  28. syntaxmatrix/settings/string_navbar.py +4 -4
  29. syntaxmatrix/static/icons/bot_icon.png +0 -0
  30. syntaxmatrix/static/icons/bot_icon2.png +0 -0
  31. syntaxmatrix/templates/admin_billing.html +408 -0
  32. syntaxmatrix/templates/admin_branding.html +65 -2
  33. syntaxmatrix/templates/admin_features.html +54 -0
  34. syntaxmatrix/templates/dashboard.html +285 -8
  35. syntaxmatrix/templates/edit_page.html +199 -18
  36. syntaxmatrix/themes.py +17 -17
  37. syntaxmatrix/workspace_db.py +0 -23
  38. syntaxmatrix-3.0.0.dist-info/METADATA +219 -0
  39. {syntaxmatrix-2.6.4.3.dist-info → syntaxmatrix-3.0.0.dist-info}/RECORD +42 -30
  40. {syntaxmatrix-2.6.4.3.dist-info → syntaxmatrix-3.0.0.dist-info}/WHEEL +1 -1
  41. syntaxmatrix/settings/default.yaml +0 -13
  42. syntaxmatrix-2.6.4.3.dist-info/METADATA +0 -539
  43. syntaxmatrix-2.6.4.3.dist-info/licenses/LICENSE.txt +0 -21
  44. /syntaxmatrix/static/icons/{logo3.png → logo2.png} +0 -0
  45. {syntaxmatrix-2.6.4.3.dist-info → syntaxmatrix-3.0.0.dist-info}/top_level.txt +0 -0
@@ -5,13 +5,12 @@ import io
5
5
  import os
6
6
  import re
7
7
  from typing import Any, Dict, List, Optional, Tuple
8
-
9
8
  import requests
10
- from PIL import Image
11
9
  from bs4 import BeautifulSoup
10
+ from PIL import Image
12
11
 
13
- PIXABAY_API_URL = "https://pixabay.com/api/"
14
12
 
13
+ PIXABAY_API_URL = "https://pixabay.com/api/"
15
14
 
16
15
  # ─────────────────────────────────────────────────────────
17
16
  # Icons (inline SVG)
@@ -373,9 +372,21 @@ def fill_layout_images_from_pixabay(
373
372
 
374
373
  return layout
375
374
 
375
+ def _make_thumb(full_path: str, thumb_path: str, max_w: int = 640, max_h: int = 420) -> bool:
376
+ """
377
+ Create a JPEG/PNG thumbnail that fits within max_w x max_h, preserving aspect ratio.
378
+ Returns True if created.
379
+ """
380
+ try:
381
+ os.makedirs(os.path.dirname(thumb_path), exist_ok=True)
382
+ with Image.open(full_path) as im:
383
+ im = im.convert("RGB") if im.mode in ("P", "RGBA") else im
384
+ im.thumbnail((max_w, max_h))
385
+ im.save(thumb_path, quality=82, optimize=True)
386
+ return True
387
+ except Exception:
388
+ return False
376
389
 
377
- import re
378
- from typing import Dict, Any, Optional
379
390
 
380
391
  def _extract_hero_image_url_from_layout(layout: Dict[str, Any]) -> str:
381
392
  """Find hero image URL from the saved layout JSON (builder)."""
@@ -710,6 +721,118 @@ def _theme_style_from_layout(layout: Dict[str, Any]) -> str:
710
721
  # ─────────────────────────────────────────────────────────
711
722
  # Compile layout JSON → modern HTML with animations
712
723
  # ─────────────────────────────────────────────────────────
724
+ _ALLOWED_RICH_TAGS = {
725
+ "p", "br", "strong", "b", "em", "i", "u",
726
+ "ul", "ol", "li", "hr",
727
+ "span", "div",
728
+ "a",
729
+ }
730
+
731
+ _ALLOWED_ATTRS = {
732
+ "span": {"style"},
733
+ "div": {"style"},
734
+ "p": {"style"},
735
+ "a": {"href", "target", "rel"},
736
+ }
737
+
738
+ _ALLOWED_STYLE_PROPS = {
739
+ "color",
740
+ "background-color",
741
+ "font-size",
742
+ "font-weight",
743
+ "font-style",
744
+ "text-decoration",
745
+ "text-align",
746
+ }
747
+
748
+ _COLOR_RE = re.compile(r"^(#[0-9a-fA-F]{3,8}|rgb\([^\)]*\)|rgba\([^\)]*\)|hsl\([^\)]*\)|hsla\([^\)]*\))$")
749
+ _SIZE_RE = re.compile(r"^\d+(\.\d+)?(px|rem|em|%)$")
750
+
751
+
752
+ def _sanitize_style(style: str) -> str:
753
+ if not style:
754
+ return ""
755
+ out = []
756
+ # split "a:b; c:d"
757
+ for part in style.split(";"):
758
+ if ":" not in part:
759
+ continue
760
+ k, v = part.split(":", 1)
761
+ k = k.strip().lower()
762
+ v = v.strip()
763
+ if k not in _ALLOWED_STYLE_PROPS:
764
+ continue
765
+
766
+ if k in ("color", "background-color"):
767
+ if not _COLOR_RE.match(v):
768
+ continue
769
+
770
+ if k == "font-size":
771
+ if not _SIZE_RE.match(v):
772
+ continue
773
+
774
+ if k == "text-align":
775
+ vv = v.lower()
776
+ if vv not in ("left", "center", "right", "justify"):
777
+ continue
778
+ v = vv
779
+
780
+ out.append(f"{k}:{v}")
781
+ return ";".join(out)
782
+
783
+
784
+ def _sanitize_rich_html(html: str) -> str:
785
+ if not html:
786
+ return ""
787
+
788
+ soup = BeautifulSoup(html, "html.parser")
789
+
790
+ # hard remove scripts/styles
791
+ for bad in soup(["script", "style"]):
792
+ bad.decompose()
793
+
794
+ for tag in list(soup.find_all(True)):
795
+ name = (tag.name or "").lower()
796
+
797
+ if name not in _ALLOWED_RICH_TAGS:
798
+ tag.unwrap()
799
+ continue
800
+
801
+ # strip attrs
802
+ allowed = _ALLOWED_ATTRS.get(name, set())
803
+ attrs = dict(tag.attrs) if tag.attrs else {}
804
+ for a in list(attrs.keys()):
805
+ if a not in allowed:
806
+ tag.attrs.pop(a, None)
807
+
808
+ # style sanitise
809
+ if "style" in tag.attrs:
810
+ s = _sanitize_style(tag.get("style") or "")
811
+ if s:
812
+ tag["style"] = s
813
+ else:
814
+ tag.attrs.pop("style", None)
815
+
816
+ # link sanitise
817
+ if name == "a":
818
+ href = (tag.get("href") or "").strip()
819
+ low = href.lower()
820
+ if low.startswith("javascript:") or low.startswith("data:"):
821
+ tag.attrs.pop("href", None)
822
+ tag["rel"] = "noopener noreferrer"
823
+
824
+ # if target set, ensure it’s safe
825
+ if tag.get("target") and tag.get("target") != "_blank":
826
+ tag.attrs.pop("target", None)
827
+
828
+ return str(soup)
829
+
830
+
831
+ def _safe_align(val: str) -> str:
832
+ v = (val or "").strip().lower()
833
+ return v if v in ("left", "center", "right", "justify") else ""
834
+
835
+
713
836
  def compile_layout_to_html(layout: Dict[str, Any], *, page_slug: str) -> str:
714
837
  page_id = re.sub(r"[^a-z0-9\-]+", "-", (page_slug or "page").lower()).strip("-") or "page"
715
838
 
@@ -759,7 +882,31 @@ def compile_layout_to_html(layout: Dict[str, Any], *, page_slug: str) -> str:
759
882
  .smxp .btn:hover{transform:translateY(-1px)}
760
883
  .smxp .grid{display:grid;gap:12px}
761
884
  .smxp .card{border:1px solid var(--bd);border-radius:var(--r);background:var(--card);padding:14px;min-width:0}
762
- .smxp .card h3{margin:10px 0 6px;font-size:1.05rem}
885
+ .smxp .card h3{
886
+ margin:10px 0 6px;
887
+ font-size:1.05rem;
888
+ line-height:1.25;
889
+ display:-webkit-box;
890
+ -webkit-line-clamp:2;
891
+ -webkit-box-orient:vertical;
892
+ overflow:hidden;
893
+ }
894
+ .smxp .smx-rich{
895
+ margin-top:8px;
896
+ color: var(--mut);
897
+ line-height:1.65;
898
+ }
899
+ .smxp .smx-rich p{margin:0 0 10px}
900
+ .smxp .smx-rich ul, .smxp .smx-rich ol{margin:0 0 10px 22px}
901
+ .smxp .smx-rich hr{
902
+ border:0;
903
+ border-top:1px solid var(--bd);
904
+ margin:14px 0;
905
+ }
906
+ .smxp .card p{
907
+ white-space: pre-wrap; /* ✅ preserve new lines */
908
+ overflow-wrap: anywhere;
909
+ }
763
910
  .smxp .icon{width:20px;height:20px;opacity:.9}
764
911
  .smxp img{width:100%;height:auto;border-radius:calc(var(--r) - 6px);display:block}
765
912
  .smxp .reveal{opacity:0;transform:translateY(14px);transition:opacity .55s ease, transform .55s ease}
@@ -767,20 +914,20 @@ def compile_layout_to_html(layout: Dict[str, Any], *, page_slug: str) -> str:
767
914
 
768
915
  .smxp .hero{ padding:0; }
769
916
  .smxp .hero-banner{
770
- position:relative;
771
- width:100%;
772
- min-height:clamp(380px, 60vh, 680px);
773
- display:flex;
774
- align-items:flex-end;
775
- overflow:hidden;
917
+ position:relative;
918
+ width:100%;
919
+ min-height:clamp(380px, 60vh, 680px);
920
+ display:flex;
921
+ align-items:flex-end;
922
+ overflow:hidden;
776
923
  }
777
924
  .smxp .hero-bg{
778
- position:absolute; inset:0;
779
- background-position:center;
780
- background-size:cover;
781
- background-repeat:no-repeat;
782
- transform:scale(1.02);
783
- filter:saturate(1.02);
925
+ position:absolute; inset:0;
926
+ background-position:center;
927
+ background-size:cover;
928
+ background-repeat:no-repeat;
929
+ transform:scale(1.02);
930
+ filter:saturate(1.02);
784
931
  }
785
932
  .smxp .hero-overlay{
786
933
  position:absolute; inset:0;
@@ -802,21 +949,208 @@ def compile_layout_to_html(layout: Dict[str, Any], *, page_slug: str) -> str:
802
949
  }
803
950
  .smxp .hero-content{ position:relative; width:100%; padding:72px 18px 48px; }
804
951
  .smxp .hero-panel{
805
- max-width:760px;
806
- border:1px solid var(--bd);
807
- background:rgba(255,255,255,.80);
808
- border-radius:var(--r);
809
- padding:18px;
810
- backdrop-filter: blur(10px);
952
+ max-width:760px;
953
+ border:1px solid rgba(148,163,184,.30);
954
+ background: rgba(2,6,23,.16); /* VERY transparent */
955
+ border-radius:var(--r);
956
+ padding:18px;
957
+
958
+ -webkit-backdrop-filter: blur(18px) saturate(155%);
959
+ backdrop-filter: blur(18px) saturate(155%);
960
+
961
+ box-shadow: 0 18px 45px rgba(2,6,23,.26);
962
+ color: rgba(248,250,252,.96);
811
963
  }
812
- @media (prefers-color-scheme: dark){
813
- .smxp .hero-panel{ background:rgba(2,6,23,.58); }
964
+
965
+ .smxp .hero-panel p{ color: rgba(226,232,240,.84); }
966
+ .smxp .hero-panel h1{
967
+ color: rgba(248,250,252,.98);
968
+ text-shadow: 0 10px 30px rgba(2,6,23,.45);
969
+ }
970
+ .smxp .hero-panel .kicker{
971
+ color: rgba(165,180,252,.95);
972
+ text-transform: uppercase;
973
+ letter-spacing: .18em;
974
+ }
975
+
976
+ .smxp .hero-panel .btn{
977
+ background: rgba(15,23,42,.42);
978
+ border-color: rgba(148,163,184,.45);
979
+ color: rgba(248,250,252,.96);
980
+ }
981
+ .smxp .hero-panel .btn-primary{
982
+ background: rgba(79,70,229,.92);
983
+ border-color: rgba(129,140,248,.70);
814
984
  }
815
- .smxp .lead{ margin-top:10px; font-size:1.05rem; line-height:1.65; }
816
985
 
817
986
  </style>
818
987
  """.strip()
819
988
 
989
+ gallery_css = """
990
+ <style>
991
+ /* Gallery: horizontal rail (ONLY) */
992
+ .smxp section[data-section-type="gallery"] .grid{
993
+ display:flex !important;
994
+ gap:14px;
995
+ overflow-x:auto;
996
+ padding: 6px 2px 12px;
997
+ scroll-snap-type:x mandatory;
998
+ -webkit-overflow-scrolling: touch;
999
+ scrollbar-gutter: stable;
1000
+ }
1001
+ /* IMAGE tiles (true gallery items) */
1002
+ .smxp section[data-section-type="gallery"] .gimg{
1003
+ flex: 0 0 auto;
1004
+ width: clamp(220px, 30vw, 380px);
1005
+ aspect-ratio: 16 / 10;
1006
+ scroll-snap-align: start;
1007
+ cursor: zoom-in;
1008
+ border:1px solid var(--bd);
1009
+ border-radius: var(--r);
1010
+ background: var(--card);
1011
+ overflow:hidden;
1012
+ position:relative;
1013
+ }
1014
+ .smxp section[data-section-type="gallery"] .gimg img{
1015
+ width:100%;
1016
+ height:100%;
1017
+ object-fit: cover;
1018
+ border-radius: 0; /* tile already rounds */
1019
+ }
1020
+ .smxp section[data-section-type="gallery"] .gimg figcaption{
1021
+ position:absolute;
1022
+ left:0; right:0; bottom:0;
1023
+ padding:10px 12px;
1024
+ background: linear-gradient(180deg, rgba(2,6,23,0) 0%, rgba(2,6,23,.55) 55%, rgba(2,6,23,.78) 100%);
1025
+ color: rgba(255,255,255,.92);
1026
+ font-weight: 700;
1027
+ font-size: .92rem;
1028
+ line-height: 1.2;
1029
+ opacity: 0;
1030
+ transform: translateY(6px);
1031
+ transition: opacity .16s ease, transform .16s ease;
1032
+ pointer-events:none;
1033
+ }
1034
+ .smxp section[data-section-type="gallery"] .gimg:hover figcaption{
1035
+ opacity:1;
1036
+ transform:none;
1037
+ }
1038
+
1039
+ /* CARD items inside a gallery rail stay as content cards (not lightbox) */
1040
+ .smxp section[data-section-type="gallery"] .card{
1041
+ flex: 0 0 auto;
1042
+ width: clamp(260px, 34vw, 420px);
1043
+ scroll-snap-align: start;
1044
+ cursor: default;
1045
+ }
1046
+
1047
+ @media (max-width: 860px){
1048
+ .smxp section[data-section-type="gallery"] .gimg{ width: 86vw; }
1049
+ .smxp section[data-section-type="gallery"] .card{ width: 86vw; }
1050
+ }
1051
+
1052
+ /* Rail nav buttons (injected by JS) */
1053
+ .smxp .smx-gallery-wrap{ position:relative; }
1054
+ .smxp .smx-gallery-nav{
1055
+ position:absolute;
1056
+ top:50%;
1057
+ transform:translateY(-50%);
1058
+ width:38px;
1059
+ height:38px;
1060
+ border-radius:999px;
1061
+ border:1px solid var(--bd);
1062
+ background: rgba(255,255,255,.82);
1063
+ color: var(--fg);
1064
+ display:flex;
1065
+ align-items:center;
1066
+ justify-content:center;
1067
+ cursor:pointer;
1068
+ z-index: 5;
1069
+ box-shadow: 0 10px 24px rgba(15,23,42,.12);
1070
+ }
1071
+ .smxp .smx-gallery-nav:hover{ transform:translateY(-50%) scale(1.03); }
1072
+ .smxp .smx-gallery-nav:active{ transform:translateY(-50%) scale(.98); }
1073
+ .smxp .smx-gallery-nav.prev{ left: 10px; }
1074
+ .smxp .smx-gallery-nav.next{ right: 10px; }
1075
+ .smxp .smx-gallery-nav[disabled]{ opacity:.35; cursor:default; }
1076
+
1077
+ /* Lightbox (“lightbulb”) overlay */
1078
+ .smxp .smx-lightbox{
1079
+ position:fixed; inset:0;
1080
+ display:none;
1081
+ align-items:center;
1082
+ justify-content:center;
1083
+ padding: 20px;
1084
+ background: rgba(2,6,23,.72);
1085
+ z-index: 9999;
1086
+ }
1087
+ .smxp .smx-lightbox.open{ display:flex; }
1088
+
1089
+ .smxp .smx-lightbox-panel{
1090
+ width:min(1100px, 96vw);
1091
+ max-height: 92vh;
1092
+ border-radius: 16px;
1093
+ border: 1px solid rgba(148,163,184,.28);
1094
+ background: rgba(255,255,255,.95);
1095
+ overflow:hidden;
1096
+ display:flex;
1097
+ flex-direction:column;
1098
+ }
1099
+ .smxp .smx-lightbox-top{
1100
+ display:flex;
1101
+ align-items:center;
1102
+ justify-content:space-between;
1103
+ gap: 10px;
1104
+ padding: 10px 12px;
1105
+ border-bottom: 1px solid rgba(148,163,184,.22);
1106
+ }
1107
+ .smxp .smx-lightbox-title{ font-weight:700; color: var(--fg); }
1108
+ .smxp .smx-lightbox-close{
1109
+ border:1px solid rgba(148,163,184,.28);
1110
+ background: rgba(255,255,255,.75);
1111
+ color: var(--fg);
1112
+ border-radius: 12px;
1113
+ padding: 8px 10px;
1114
+ cursor:pointer;
1115
+ }
1116
+ .smxp .smx-lightbox-body{
1117
+ position:relative;
1118
+ padding: 12px;
1119
+ display:grid;
1120
+ gap: 10px;
1121
+ }
1122
+ .smxp .smx-lightbox-img{
1123
+ width:100%;
1124
+ max-height: 68vh;
1125
+ object-fit: contain;
1126
+ border-radius: 14px;
1127
+ background: rgba(0,0,0,.04);
1128
+ }
1129
+ .smxp .smx-lightbox-caption{
1130
+ color: var(--mut);
1131
+ line-height: 1.6;
1132
+ }
1133
+ .smxp .smx-lightbox-arrow{
1134
+ position:absolute;
1135
+ top:50%;
1136
+ transform:translateY(-50%);
1137
+ width:44px;
1138
+ height:44px;
1139
+ border-radius:999px;
1140
+ border:1px solid rgba(148,163,184,.28);
1141
+ background: rgba(255,255,255,.82);
1142
+ color: var(--fg);
1143
+ display:flex;
1144
+ align-items:center;
1145
+ justify-content:center;
1146
+ cursor:pointer;
1147
+ }
1148
+ .smxp .smx-lightbox-arrow.prev{ left: 10px; }
1149
+ .smxp .smx-lightbox-arrow.next{ right: 10px; }
1150
+ .smxp .smx-lightbox-arrow[disabled]{ opacity:.35; cursor:default; }
1151
+ </style>
1152
+ """.strip()
1153
+
820
1154
  js = f"""
821
1155
  <script>
822
1156
  (function(){{
@@ -831,11 +1165,226 @@ def compile_layout_to_html(layout: Dict[str, Any], *, page_slug: str) -> str:
831
1165
  </script>
832
1166
  """.strip()
833
1167
 
1168
+ gallery_js = f"""
1169
+ <script>
1170
+ (function(){{
1171
+ const root = document.getElementById("smxp-{page_id}") || document.querySelector(".smxp");
1172
+ if(!root) return;
1173
+
1174
+ const gallerySecs = root.querySelectorAll('section[data-section-type="gallery"]');
1175
+ if(!gallerySecs.length) return;
1176
+
1177
+ // Ensure single lightbox per page
1178
+ let lb = root.querySelector(".smx-lightbox");
1179
+ if(!lb){{
1180
+ lb = document.createElement("div");
1181
+ lb.className = "smx-lightbox";
1182
+ lb.innerHTML = `
1183
+ <div class="smx-lightbox-panel" role="dialog" aria-modal="true">
1184
+ <div class="smx-lightbox-top">
1185
+ <div class="smx-lightbox-title"></div>
1186
+ <button class="smx-lightbox-close" type="button">Close</button>
1187
+ </div>
1188
+ <div class="smx-lightbox-body">
1189
+ <button class="smx-lightbox-arrow prev" type="button" aria-label="Previous">‹</button>
1190
+ <img class="smx-lightbox-img" alt="" />
1191
+ <button class="smx-lightbox-arrow next" type="button" aria-label="Next">›</button>
1192
+ <div class="smx-lightbox-caption"></div>
1193
+ </div>
1194
+ </div>`;
1195
+ root.appendChild(lb);
1196
+ }}
1197
+
1198
+ const lbTitle = lb.querySelector(".smx-lightbox-title");
1199
+ const lbImg = lb.querySelector(".smx-lightbox-img");
1200
+ const lbCap = lb.querySelector(".smx-lightbox-caption");
1201
+ const btnClose= lb.querySelector(".smx-lightbox-close");
1202
+ const btnPrev = lb.querySelector(".smx-lightbox-arrow.prev");
1203
+ const btnNext = lb.querySelector(".smx-lightbox-arrow.next");
1204
+
1205
+ let slides = [];
1206
+ let idx = 0;
1207
+ let lastFocus = null;
1208
+ let prevOverflow = "";
1209
+
1210
+ function render(){{
1211
+ const s = slides[idx];
1212
+ if(!s) return;
1213
+ lbImg.src = s.src;
1214
+ lbImg.alt = s.title || "image";
1215
+ lbTitle.textContent = s.title || "";
1216
+ lbCap.textContent = s.caption || "";
1217
+ btnPrev.disabled = (idx <= 0);
1218
+ btnNext.disabled = (idx >= slides.length - 1);
1219
+ }}
1220
+
1221
+ function openAt(i){{
1222
+ idx = Math.max(0, Math.min(i, slides.length - 1));
1223
+ lastFocus = document.activeElement;
1224
+ prevOverflow = document.body.style.overflow;
1225
+ document.body.style.overflow = "hidden";
1226
+ lb.classList.add("open");
1227
+ render();
1228
+ btnClose.focus();
1229
+ }}
1230
+
1231
+ function close(){{
1232
+ lb.classList.remove("open");
1233
+ document.body.style.overflow = prevOverflow || "";
1234
+ if(lastFocus && lastFocus.focus) lastFocus.focus();
1235
+ }}
1236
+
1237
+ function move(d){{
1238
+ const ni = idx + d;
1239
+ if(ni < 0 || ni >= slides.length) return;
1240
+ idx = ni;
1241
+ render();
1242
+ }}
1243
+
1244
+ btnClose.addEventListener("click", close);
1245
+ lb.addEventListener("click", (e)=> {{ if(e.target === lb) close(); }});
1246
+ btnPrev.addEventListener("click", ()=> move(-1));
1247
+ btnNext.addEventListener("click", ()=> move(+1));
1248
+
1249
+ document.addEventListener("keydown", (e)=> {{
1250
+ if(!lb.classList.contains("open")) return;
1251
+ if(e.key === "Escape") return close();
1252
+ if(e.key === "ArrowLeft") return move(-1);
1253
+ if(e.key === "ArrowRight") return move(+1);
1254
+ }});
1255
+
1256
+ function ensureNav(sec, rail){{
1257
+ let wrap = sec.querySelector(".smx-gallery-wrap");
1258
+ if(!wrap){{
1259
+ wrap = document.createElement("div");
1260
+ wrap.className = "smx-gallery-wrap";
1261
+ rail.parentNode.insertBefore(wrap, rail);
1262
+ wrap.appendChild(rail);
1263
+ }}
1264
+
1265
+ let prev = wrap.querySelector("button.smx-gallery-nav.prev");
1266
+ let next = wrap.querySelector("button.smx-gallery-nav.next");
1267
+
1268
+ if(!prev){{
1269
+ prev = document.createElement("button");
1270
+ prev.className = "smx-gallery-nav prev";
1271
+ prev.type = "button";
1272
+ prev.setAttribute("aria-label","Scroll left");
1273
+ prev.innerHTML = "‹";
1274
+ wrap.appendChild(prev);
1275
+ }}
1276
+ if(!next){{
1277
+ next = document.createElement("button");
1278
+ next.className = "smx-gallery-nav next";
1279
+ next.type = "button";
1280
+ next.setAttribute("aria-label","Scroll right");
1281
+ next.innerHTML = "›";
1282
+ wrap.appendChild(next);
1283
+ }}
1284
+
1285
+ function update(){{
1286
+ const max = rail.scrollWidth - rail.clientWidth;
1287
+ const canScroll = max > 4;
1288
+ prev.style.display = canScroll ? "" : "none";
1289
+ next.style.display = canScroll ? "" : "none";
1290
+ prev.disabled = rail.scrollLeft <= 2;
1291
+ next.disabled = rail.scrollLeft >= (max - 2);
1292
+ }}
1293
+
1294
+ prev.addEventListener("click", ()=> {{
1295
+ if(prev.disabled) return;
1296
+ rail.scrollBy({{ left: -Math.max(260, rail.clientWidth * 0.85), behavior:"smooth" }});
1297
+ }});
1298
+ next.addEventListener("click", ()=> {{
1299
+ if(next.disabled) return;
1300
+ rail.scrollBy({{ left: Math.max(260, rail.clientWidth * 0.85), behavior:"smooth" }});
1301
+ }});
1302
+
1303
+ rail.addEventListener("scroll", update, {{ passive:true }});
1304
+ window.addEventListener("resize", update);
1305
+ update();
1306
+ }}
1307
+
1308
+ function initGallerySection(sec){{
1309
+ const rail = sec.querySelector(".grid");
1310
+ if(!rail) return;
1311
+
1312
+ ensureNav(sec, rail);
1313
+
1314
+ // Build slides from IMAGE tiles only (cards remain content cards)
1315
+ const tiles = Array.from(rail.querySelectorAll('[data-item-type="image"]'));
1316
+ if(!tiles.length) return;
1317
+
1318
+ // Build a local slide list for THIS rail
1319
+ const localSlides = [];
1320
+ tiles.forEach(tile => {{
1321
+ const img = tile.querySelector("img");
1322
+ if(!img) return;
1323
+
1324
+ const full = (tile.getAttribute("data-full") || img.getAttribute("data-full") || img.getAttribute("src") || "").trim();
1325
+ if(!full) return;
1326
+
1327
+ const title = (tile.getAttribute("data-title") || "").trim();
1328
+ const caption = (tile.getAttribute("data-caption") || "").trim();
1329
+
1330
+ const slideIndex = localSlides.length;
1331
+ localSlides.push({{ src: full, title, caption }});
1332
+
1333
+ tile.dataset.smxSlide = String(slideIndex);
1334
+
1335
+ // IMPORTANT: don’t double-bind (dynamic inserts / re-init safe)
1336
+ if(tile.dataset.smxBound === "1") return;
1337
+ tile.dataset.smxBound = "1";
1338
+
1339
+ tile.addEventListener("click", (e)=> {{
1340
+ if(e.target && e.target.closest && e.target.closest("a,button")) return;
1341
+ slides = localSlides; // activate the correct slide set for this rail
1342
+ const i = parseInt(tile.dataset.smxSlide || "0", 10);
1343
+ openAt(i);
1344
+ }});
1345
+ }});
1346
+ }}
1347
+
1348
+ gallerySecs.forEach(sec => initGallerySection(sec));
1349
+
1350
+ // Re-init when new gallery sections or new tiles are inserted dynamically (Admin editor)
1351
+ const obs = new MutationObserver((mutations) => {{
1352
+ for(const m of mutations){{
1353
+ if(m.type !== "childList") continue;
1354
+
1355
+ // If a gallery section is added, init it
1356
+ m.addedNodes && m.addedNodes.forEach(node => {{
1357
+ if(!(node instanceof Element)) return;
1358
+
1359
+ if(node.matches && node.matches('section[data-section-type="gallery"]')){{
1360
+ initGallerySection(node);
1361
+ return;
1362
+ }}
1363
+
1364
+ // If anything added inside/near a gallery section, init the nearest section
1365
+ const sec = node.closest ? node.closest('section[data-section-type="gallery"]') : null;
1366
+ if(sec) initGallerySection(sec);
1367
+ }});
1368
+ }}
1369
+ }});
1370
+
1371
+ obs.observe(root, {{ childList: true, subtree: true }});
1372
+
1373
+ }})();
1374
+ </script>
1375
+ """.strip()
1376
+
1377
+
834
1378
  def esc(s: str) -> str:
835
1379
  s = s or ""
836
1380
  s = s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
837
1381
  s = s.replace('"', "&quot;").replace("'", "&#39;")
838
1382
  return s
1383
+
1384
+ def esc_nl(s: str) -> str:
1385
+ # preserve paragraphs in plain text
1386
+ return esc(s).replace("\n", "<br>")
1387
+
839
1388
 
840
1389
  def icon_svg(name: str) -> str:
841
1390
  svg = _ICON_SVGS.get((name or "").strip().lower())
@@ -843,7 +1392,7 @@ def compile_layout_to_html(layout: Dict[str, Any], *, page_slug: str) -> str:
843
1392
  return ""
844
1393
  return f'<span class="icon">{svg}</span>'
845
1394
 
846
- parts: List[str] = [f'<div class="smxp" id="smxp-{page_id}">', css]
1395
+ parts: List[str] = [f'<div class="smxp" id="smxp-{page_id}">', css, gallery_css]
847
1396
  sections = layout.get("sections") if isinstance(layout.get("sections"), list) else []
848
1397
 
849
1398
  # Map first section id by type (used for default Hero CTA anchors)
@@ -869,7 +1418,7 @@ def compile_layout_to_html(layout: Dict[str, Any], *, page_slug: str) -> str:
869
1418
  for s in sections:
870
1419
  stype = (s.get("type") or "section").lower()
871
1420
  title = esc(s.get("title") or "")
872
- text = esc(s.get("text") or "")
1421
+ text = esc_nl(s.get("text") or "")
873
1422
  items = s.get("items") if isinstance(s.get("items"), list) else []
874
1423
  sec_dom_id = (s.get("id") or "").strip()
875
1424
  if not sec_dom_id:
@@ -913,10 +1462,10 @@ def compile_layout_to_html(layout: Dict[str, Any], *, page_slug: str) -> str:
913
1462
  )
914
1463
 
915
1464
  btn_row_html = f'<div class="btnRow">{"".join(btns)}</div>' if btns else ""
916
-
1465
+
917
1466
  parts.append(
918
1467
  f'''
919
- <section id="{esc(sec_dom_id)}" class="hero hero-banner">
1468
+ <section id="{esc(sec_dom_id)}" class="hero hero-banner" data-section-type="hero">
920
1469
  <div class="hero-bg"{bg_style}></div>
921
1470
  <div class="hero-overlay"></div>
922
1471
  <div class="wrap hero-content">
@@ -939,38 +1488,94 @@ def compile_layout_to_html(layout: Dict[str, Any], *, page_slug: str) -> str:
939
1488
  cols = 3
940
1489
  cols = max(1, min(5, cols))
941
1490
 
942
- cards: List[str] = []
1491
+ tiles: List[str] = []
943
1492
  for it in items:
944
1493
  if not isinstance(it, dict):
945
1494
  continue
946
- it_title = esc(it.get("title") or "")
947
- it_text = esc(it.get("text") or "")
948
- img = (it.get("imageUrl") or "").strip()
1495
+
1496
+ it_type = str(it.get("type") or "card").lower().strip()
1497
+
1498
+ title_plain = (it.get("title") or "").strip()
1499
+ cap_plain = (it.get("text") or "").strip()
1500
+
1501
+ it_title = esc(title_plain)
1502
+ it_text = esc_nl(cap_plain)
1503
+
1504
+ full = (it.get("imageUrl") or "").strip()
1505
+ thumb = (it.get("thumbUrl") or it.get("thumb_url") or "").strip()
1506
+ src = thumb or full # rail prefers thumb if available
1507
+
1508
+ # IMAGE tiles: true gallery behaviour (thumb in rail, full in lightbox)
1509
+ if stype == "gallery" and it_type == "image":
1510
+ if not src:
1511
+ continue
1512
+ full_for_lb = full or src
1513
+ tiles.append(
1514
+ f'''
1515
+ <figure class="gimg reveal"
1516
+ data-item-type="image"
1517
+ data-full="{esc(full_for_lb)}"
1518
+ data-title="{esc(title_plain)}"
1519
+ data-caption="{esc(cap_plain)}">
1520
+ <img loading="lazy" decoding="async"
1521
+ src="{esc(src)}"
1522
+ data-full="{esc(full_for_lb)}"
1523
+ alt="{esc(title_plain or 'image')}">
1524
+ {f'<figcaption>{it_title}</figcaption>' if title_plain else ''}
1525
+ </figure>
1526
+ '''.strip()
1527
+ )
1528
+ continue
1529
+
1530
+ # Default: render as a content card (Card, FAQ, Quote, etc.)
949
1531
  ic = icon_svg(it.get("icon") or "")
1532
+ img_html = f'<img loading="lazy" decoding="async" src="{esc(full)}" alt="{it_title}">' if full else ""
1533
+
1534
+ title_align = _safe_align(str(it.get("titleAlign") or ""))
1535
+ text_align = _safe_align(str(it.get("textAlign") or ""))
1536
+
1537
+ title_align_css = f"text-align:{title_align};" if title_align else ""
1538
+ text_align_css = f"text-align:{text_align};" if text_align else ""
950
1539
 
951
- img_html = f'<img loading="lazy" decoding="async" src="{esc(img)}" alt="{it_title}">' if img else ""
952
- cards.append(
1540
+ raw_html = str(it.get("textHtml") or "").strip()
1541
+ if raw_html:
1542
+ safe_html = _sanitize_rich_html(raw_html)
1543
+ body_html = f'<div class="smx-rich" style="{text_align_css}">{safe_html}</div>'
1544
+ else:
1545
+ body_html = f'<p style="margin-top:8px;{text_align_css}">{it_text}</p>'
1546
+
1547
+ tiles.append(
953
1548
  f'''
954
- <div class="card reveal">
1549
+ <div class="card reveal" data-item-type="{esc(it_type)}">
955
1550
  {img_html}
956
- <div style="display:flex;gap:10px;align-items:center;margin-top:{'10px' if img_html else '0'};">
1551
+ <div style="display:flex;gap:10px;align-items:flex-start;margin-top:{'10px' if img_html else '0'};">
957
1552
  {ic}
958
- <h3 style="margin:0">{it_title}</h3>
1553
+ <h3 style="margin:0;{title_align_css}">{it_title}</h3>
959
1554
  </div>
960
- <p style="margin-top:8px">{it_text}</p>
1555
+ {body_html}
961
1556
  </div>
962
1557
  '''.strip()
963
1558
  )
964
1559
 
965
- grid_html = (
966
- f'<div class="grid" style="grid-template-columns:repeat({cols}, minmax(0, 1fr));">'
967
- + "\n".join(cards) +
968
- "</div>"
969
- ) if cards else ""
1560
+ if tiles:
1561
+ if stype == "gallery":
1562
+ grid_html = '<div class="grid">' + "\n".join(tiles) + "</div>"
1563
+ else:
1564
+ grid_html = (
1565
+ f'<div class="grid" style="grid-template-columns:repeat({cols}, minmax(0, 1fr));">'
1566
+ + "\n".join(tiles) +
1567
+ "</div>"
1568
+ )
1569
+ else:
1570
+ grid_html = ""
1571
+
1572
+
1573
+ sec_class = "sec sec-gallery" if stype == "gallery" else "sec"
1574
+ sec_extra_attr = ' data-section-type="gallery"' if stype == "gallery" else ""
970
1575
 
971
1576
  parts.append(
972
1577
  f'''
973
- <section id="{esc(sec_dom_id)}" class="sec">
1578
+ <section id="{esc(sec_dom_id)}" class="sec" data-section-type="{esc(stype or 'section')}">
974
1579
  <div class="wrap">
975
1580
  <h2 class="reveal">{title}</h2>
976
1581
  {'<p class="reveal" style="margin-bottom:14px;">'+text+'</p>' if text else ''}
@@ -981,12 +1586,11 @@ def compile_layout_to_html(layout: Dict[str, Any], *, page_slug: str) -> str:
981
1586
  )
982
1587
 
983
1588
  parts.append(js)
1589
+ parts.append(gallery_js)
984
1590
  parts.append("</div>")
985
- return "\n\n".join(parts)
986
1591
 
1592
+ return "\n\n".join(parts)
987
1593
 
988
- from bs4 import BeautifulSoup
989
- from typing import Dict, Any, List, Tuple
990
1594
 
991
1595
  def _layout_non_hero_sections(layout: Dict[str, Any]) -> List[Dict[str, Any]]:
992
1596
  sections = layout.get("sections") if isinstance(layout.get("sections"), list) else []