syntaxmatrix 2.6.4.4__py3-none-any.whl → 3.0.1__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 (41) hide show
  1. syntaxmatrix/__init__.py +6 -4
  2. syntaxmatrix/agentic/agents.py +206 -26
  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 +142 -56
  7. syntaxmatrix/dataset_preprocessing.py +2 -2
  8. syntaxmatrix/db.py +0 -17
  9. syntaxmatrix/kernel_manager.py +174 -150
  10. syntaxmatrix/page_builder_generation.py +656 -63
  11. syntaxmatrix/page_layout_contract.py +25 -3
  12. syntaxmatrix/page_patch_publish.py +368 -15
  13. syntaxmatrix/plugins/__init__.py +0 -0
  14. syntaxmatrix/premium/__init__.py +10 -2
  15. syntaxmatrix/premium/catalogue/__init__.py +121 -0
  16. syntaxmatrix/premium/gate.py +15 -3
  17. syntaxmatrix/premium/state.py +507 -0
  18. syntaxmatrix/premium/verify.py +222 -0
  19. syntaxmatrix/profiles.py +1 -1
  20. syntaxmatrix/routes.py +9847 -8004
  21. syntaxmatrix/settings/model_map.py +50 -65
  22. syntaxmatrix/settings/prompts.py +1186 -414
  23. syntaxmatrix/settings/string_navbar.py +4 -4
  24. syntaxmatrix/static/icons/bot_icon.png +0 -0
  25. syntaxmatrix/static/icons/bot_icon2.png +0 -0
  26. syntaxmatrix/templates/admin_billing.html +408 -0
  27. syntaxmatrix/templates/admin_branding.html +65 -2
  28. syntaxmatrix/templates/admin_features.html +54 -0
  29. syntaxmatrix/templates/dashboard.html +285 -8
  30. syntaxmatrix/templates/edit_page.html +199 -18
  31. syntaxmatrix/themes.py +17 -17
  32. syntaxmatrix/workspace_db.py +0 -23
  33. syntaxmatrix-3.0.1.dist-info/METADATA +219 -0
  34. {syntaxmatrix-2.6.4.4.dist-info → syntaxmatrix-3.0.1.dist-info}/RECORD +38 -33
  35. {syntaxmatrix-2.6.4.4.dist-info → syntaxmatrix-3.0.1.dist-info}/WHEEL +1 -1
  36. syntaxmatrix/settings/default.yaml +0 -13
  37. syntaxmatrix-2.6.4.4.dist-info/METADATA +0 -539
  38. syntaxmatrix-2.6.4.4.dist-info/licenses/LICENSE.txt +0 -21
  39. /syntaxmatrix/{plugin_manager.py → plugins/plugin_manager.py} +0 -0
  40. /syntaxmatrix/static/icons/{logo3.png → logo2.png} +0 -0
  41. {syntaxmatrix-2.6.4.4.dist-info → syntaxmatrix-3.0.1.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,34 +721,135 @@ 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
 
716
839
  css = """
717
840
  <style>
718
- .smxp{--r:18px;--bd:rgba(148,163,184,.28);--mut:#94a3b8;--fg:#0f172a;--card:rgba(255,255,255,.72);
719
841
  .smxp{
720
842
  --r:18px;
721
843
  --bd: rgba(148,163,184,.25);
722
844
  --fg: #0f172a;
723
- --mut: #475569; /* <- darker, readable */
845
+ --mut:#000000; /* <- darker, readable */
724
846
  --card: rgba(255,255,255,.78);
725
847
  --bg: #f8fafc;
726
848
  font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
727
849
  background: var(--bg);
728
- color: var(--fg);
729
850
  overflow-x: clip;
730
851
  }
731
- @media (prefers-color-scheme: dark){
732
- .smxp{
733
- --fg:#e2e8f0;
734
- --mut:#a7b3c6;
735
- --card:rgba(2,6,23,.45);
736
- --bg: radial-gradient(circle at 20% 10%, rgba(30,64,175,.25), rgba(2,6,23,.95) 55%);
737
- --bd: rgba(148,163,184,.18);
738
- }
739
- }
740
-
852
+
741
853
  font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; color: var(--fg); }
742
854
  @media (prefers-color-scheme: dark){
743
855
  .smxp{--fg:#e2e8f0;--card:rgba(2,6,23,.45);--bd:rgba(148,163,184,.18);--mut:#a7b3c6;}
@@ -759,7 +871,31 @@ def compile_layout_to_html(layout: Dict[str, Any], *, page_slug: str) -> str:
759
871
  .smxp .btn:hover{transform:translateY(-1px)}
760
872
  .smxp .grid{display:grid;gap:12px}
761
873
  .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}
874
+ .smxp .card h3{
875
+ margin:10px 0 6px;
876
+ font-size:1.05rem;
877
+ line-height:1.25;
878
+ display:-webkit-box;
879
+ -webkit-line-clamp:2;
880
+ -webkit-box-orient:vertical;
881
+ overflow:hidden;
882
+ }
883
+ .smxp .smx-rich{
884
+ margin-top:8px;
885
+ color: var(--mut);
886
+ line-height:1.65;
887
+ }
888
+ .smxp .smx-rich p{margin:0 0 10px}
889
+ .smxp .smx-rich ul, .smxp .smx-rich ol{margin:0 0 10px 22px}
890
+ .smxp .smx-rich hr{
891
+ border:0;
892
+ border-top:1px solid var(--bd);
893
+ margin:14px 0;
894
+ }
895
+ .smxp .card p{
896
+ white-space: pre-wrap; /* ✅ preserve new lines */
897
+ overflow-wrap: anywhere;
898
+ }
763
899
  .smxp .icon{width:20px;height:20px;opacity:.9}
764
900
  .smxp img{width:100%;height:auto;border-radius:calc(var(--r) - 6px);display:block}
765
901
  .smxp .reveal{opacity:0;transform:translateY(14px);transition:opacity .55s ease, transform .55s ease}
@@ -767,20 +903,20 @@ def compile_layout_to_html(layout: Dict[str, Any], *, page_slug: str) -> str:
767
903
 
768
904
  .smxp .hero{ padding:0; }
769
905
  .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;
906
+ position:relative;
907
+ width:100%;
908
+ min-height:clamp(380px, 60vh, 680px);
909
+ display:flex;
910
+ align-items:flex-end;
911
+ overflow:hidden;
776
912
  }
777
913
  .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);
914
+ position:absolute; inset:0;
915
+ background-position:center;
916
+ background-size:cover;
917
+ background-repeat:no-repeat;
918
+ transform:scale(1.02);
919
+ filter:saturate(1.02);
784
920
  }
785
921
  .smxp .hero-overlay{
786
922
  position:absolute; inset:0;
@@ -802,18 +938,205 @@ def compile_layout_to_html(layout: Dict[str, Any], *, page_slug: str) -> str:
802
938
  }
803
939
  .smxp .hero-content{ position:relative; width:100%; padding:72px 18px 48px; }
804
940
  .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);
941
+ max-width:760px;
942
+ border:1px solid rgba(148,163,184,.30);
943
+ background: rgba(2,6,23,.16); /* VERY transparent */
944
+ border-radius:var(--r);
945
+ padding:18px;
946
+
947
+ -webkit-backdrop-filter: blur(18px) saturate(155%);
948
+ backdrop-filter: blur(18px) saturate(155%);
949
+
950
+ box-shadow: 0 18px 45px rgba(2,6,23,.26);
951
+ color: rgba(248,250,252,.96);
811
952
  }
812
- @media (prefers-color-scheme: dark){
813
- .smxp .hero-panel{ background:rgba(2,6,23,.58); }
953
+
954
+ .smxp .hero-panel p{ color: rgba(226,232,240,.84); }
955
+ .smxp .hero-panel h1{
956
+ color: rgba(248,250,252,.98);
957
+ text-shadow: 0 10px 30px rgba(2,6,23,.45);
958
+ }
959
+ .smxp .hero-panel .kicker{
960
+ color: rgba(165,180,252,.95);
961
+ text-transform: uppercase;
962
+ letter-spacing: .18em;
963
+ }
964
+
965
+ .smxp .hero-panel .btn{
966
+ background: rgba(15,23,42,.42);
967
+ border-color: rgba(148,163,184,.45);
968
+ color: rgba(248,250,252,.96);
969
+ }
970
+ .smxp .hero-panel .btn-primary{
971
+ background: rgba(79,70,229,.92);
972
+ border-color: rgba(129,140,248,.70);
973
+ }
974
+
975
+ </style>
976
+ """.strip()
977
+
978
+ gallery_css = """
979
+ <style>
980
+ /* Gallery: horizontal rail (ONLY) */
981
+ .smxp section[data-section-type="gallery"] .grid{
982
+ display:flex !important;
983
+ gap:14px;
984
+ overflow-x:auto;
985
+ padding: 6px 2px 12px;
986
+ scroll-snap-type:x mandatory;
987
+ -webkit-overflow-scrolling: touch;
988
+ scrollbar-gutter: stable;
989
+ }
990
+ /* IMAGE tiles (true gallery items) */
991
+ .smxp section[data-section-type="gallery"] .gimg{
992
+ flex: 0 0 auto;
993
+ width: clamp(220px, 30vw, 380px);
994
+ aspect-ratio: 16 / 10;
995
+ scroll-snap-align: start;
996
+ cursor: zoom-in;
997
+ border:1px solid var(--bd);
998
+ border-radius: var(--r);
999
+ background: var(--card);
1000
+ overflow:hidden;
1001
+ position:relative;
1002
+ }
1003
+ .smxp section[data-section-type="gallery"] .gimg img{
1004
+ width:100%;
1005
+ height:100%;
1006
+ object-fit: cover;
1007
+ border-radius: 0; /* tile already rounds */
1008
+ }
1009
+ .smxp section[data-section-type="gallery"] .gimg figcaption{
1010
+ position:absolute;
1011
+ left:0; right:0; bottom:0;
1012
+ padding:10px 12px;
1013
+ background: linear-gradient(180deg, rgba(2,6,23,0) 0%, rgba(2,6,23,.55) 55%, rgba(2,6,23,.78) 100%);
1014
+ color: rgba(255,255,255,.92);
1015
+ font-weight: 700;
1016
+ font-size: .92rem;
1017
+ line-height: 1.2;
1018
+ opacity: 0;
1019
+ transform: translateY(6px);
1020
+ transition: opacity .16s ease, transform .16s ease;
1021
+ pointer-events:none;
1022
+ }
1023
+ .smxp section[data-section-type="gallery"] .gimg:hover figcaption{
1024
+ opacity:1;
1025
+ transform:none;
1026
+ }
1027
+
1028
+ /* CARD items inside a gallery rail stay as content cards (not lightbox) */
1029
+ .smxp section[data-section-type="gallery"] .card{
1030
+ flex: 0 0 auto;
1031
+ width: clamp(260px, 34vw, 420px);
1032
+ scroll-snap-align: start;
1033
+ cursor: default;
1034
+ }
1035
+
1036
+ @media (max-width: 860px){
1037
+ .smxp section[data-section-type="gallery"] .gimg{ width: 86vw; }
1038
+ .smxp section[data-section-type="gallery"] .card{ width: 86vw; }
814
1039
  }
815
- .smxp .lead{ margin-top:10px; font-size:1.05rem; line-height:1.65; }
816
1040
 
1041
+ /* Rail nav buttons (injected by JS) */
1042
+ .smxp .smx-gallery-wrap{ position:relative; }
1043
+ .smxp .smx-gallery-nav{
1044
+ position:absolute;
1045
+ top:50%;
1046
+ transform:translateY(-50%);
1047
+ width:38px;
1048
+ height:38px;
1049
+ border-radius:999px;
1050
+ border:1px solid var(--bd);
1051
+ background: rgba(255,255,255,.82);
1052
+ color: var(--fg);
1053
+ display:flex;
1054
+ align-items:center;
1055
+ justify-content:center;
1056
+ cursor:pointer;
1057
+ z-index: 5;
1058
+ box-shadow: 0 10px 24px rgba(15,23,42,.12);
1059
+ }
1060
+ .smxp .smx-gallery-nav:hover{ transform:translateY(-50%) scale(1.03); }
1061
+ .smxp .smx-gallery-nav:active{ transform:translateY(-50%) scale(.98); }
1062
+ .smxp .smx-gallery-nav.prev{ left: 10px; }
1063
+ .smxp .smx-gallery-nav.next{ right: 10px; }
1064
+ .smxp .smx-gallery-nav[disabled]{ opacity:.35; cursor:default; }
1065
+
1066
+ /* Lightbox (“lightbulb”) overlay */
1067
+ .smxp .smx-lightbox{
1068
+ position:fixed; inset:0;
1069
+ display:none;
1070
+ align-items:center;
1071
+ justify-content:center;
1072
+ padding: 20px;
1073
+ background: rgba(2,6,23,.72);
1074
+ z-index: 9999;
1075
+ }
1076
+ .smxp .smx-lightbox.open{ display:flex; }
1077
+
1078
+ .smxp .smx-lightbox-panel{
1079
+ width:min(1100px, 96vw);
1080
+ max-height: 92vh;
1081
+ border-radius: 16px;
1082
+ border: 1px solid rgba(148,163,184,.28);
1083
+ background: rgba(255,255,255,.95);
1084
+ overflow:hidden;
1085
+ display:flex;
1086
+ flex-direction:column;
1087
+ }
1088
+ .smxp .smx-lightbox-top{
1089
+ display:flex;
1090
+ align-items:center;
1091
+ justify-content:space-between;
1092
+ gap: 10px;
1093
+ padding: 10px 12px;
1094
+ border-bottom: 1px solid rgba(148,163,184,.22);
1095
+ }
1096
+ .smxp .smx-lightbox-title{ font-weight:700; color: var(--fg); }
1097
+ .smxp .smx-lightbox-close{
1098
+ border:1px solid rgba(148,163,184,.28);
1099
+ background: rgba(255,255,255,.75);
1100
+ color: var(--fg);
1101
+ border-radius: 12px;
1102
+ padding: 8px 10px;
1103
+ cursor:pointer;
1104
+ }
1105
+ .smxp .smx-lightbox-body{
1106
+ position:relative;
1107
+ padding: 12px;
1108
+ display:grid;
1109
+ gap: 10px;
1110
+ }
1111
+ .smxp .smx-lightbox-img{
1112
+ width:100%;
1113
+ max-height: 68vh;
1114
+ object-fit: contain;
1115
+ border-radius: 14px;
1116
+ background: rgba(0,0,0,.04);
1117
+ }
1118
+ .smxp .smx-lightbox-caption{
1119
+ color: var(--mut);
1120
+ line-height: 1.6;
1121
+ }
1122
+ .smxp .smx-lightbox-arrow{
1123
+ position:absolute;
1124
+ top:50%;
1125
+ transform:translateY(-50%);
1126
+ width:44px;
1127
+ height:44px;
1128
+ border-radius:999px;
1129
+ border:1px solid rgba(148,163,184,.28);
1130
+ background: rgba(255,255,255,.82);
1131
+ color: var(--fg);
1132
+ display:flex;
1133
+ align-items:center;
1134
+ justify-content:center;
1135
+ cursor:pointer;
1136
+ }
1137
+ .smxp .smx-lightbox-arrow.prev{ left: 10px; }
1138
+ .smxp .smx-lightbox-arrow.next{ right: 10px; }
1139
+ .smxp .smx-lightbox-arrow[disabled]{ opacity:.35; cursor:default; }
817
1140
  </style>
818
1141
  """.strip()
819
1142
 
@@ -831,11 +1154,226 @@ def compile_layout_to_html(layout: Dict[str, Any], *, page_slug: str) -> str:
831
1154
  </script>
832
1155
  """.strip()
833
1156
 
1157
+ gallery_js = f"""
1158
+ <script>
1159
+ (function(){{
1160
+ const root = document.getElementById("smxp-{page_id}") || document.querySelector(".smxp");
1161
+ if(!root) return;
1162
+
1163
+ const gallerySecs = root.querySelectorAll('section[data-section-type="gallery"]');
1164
+ if(!gallerySecs.length) return;
1165
+
1166
+ // Ensure single lightbox per page
1167
+ let lb = root.querySelector(".smx-lightbox");
1168
+ if(!lb){{
1169
+ lb = document.createElement("div");
1170
+ lb.className = "smx-lightbox";
1171
+ lb.innerHTML = `
1172
+ <div class="smx-lightbox-panel" role="dialog" aria-modal="true">
1173
+ <div class="smx-lightbox-top">
1174
+ <div class="smx-lightbox-title"></div>
1175
+ <button class="smx-lightbox-close" type="button">Close</button>
1176
+ </div>
1177
+ <div class="smx-lightbox-body">
1178
+ <button class="smx-lightbox-arrow prev" type="button" aria-label="Previous">‹</button>
1179
+ <img class="smx-lightbox-img" alt="" />
1180
+ <button class="smx-lightbox-arrow next" type="button" aria-label="Next">›</button>
1181
+ <div class="smx-lightbox-caption"></div>
1182
+ </div>
1183
+ </div>`;
1184
+ root.appendChild(lb);
1185
+ }}
1186
+
1187
+ const lbTitle = lb.querySelector(".smx-lightbox-title");
1188
+ const lbImg = lb.querySelector(".smx-lightbox-img");
1189
+ const lbCap = lb.querySelector(".smx-lightbox-caption");
1190
+ const btnClose= lb.querySelector(".smx-lightbox-close");
1191
+ const btnPrev = lb.querySelector(".smx-lightbox-arrow.prev");
1192
+ const btnNext = lb.querySelector(".smx-lightbox-arrow.next");
1193
+
1194
+ let slides = [];
1195
+ let idx = 0;
1196
+ let lastFocus = null;
1197
+ let prevOverflow = "";
1198
+
1199
+ function render(){{
1200
+ const s = slides[idx];
1201
+ if(!s) return;
1202
+ lbImg.src = s.src;
1203
+ lbImg.alt = s.title || "image";
1204
+ lbTitle.textContent = s.title || "";
1205
+ lbCap.textContent = s.caption || "";
1206
+ btnPrev.disabled = (idx <= 0);
1207
+ btnNext.disabled = (idx >= slides.length - 1);
1208
+ }}
1209
+
1210
+ function openAt(i){{
1211
+ idx = Math.max(0, Math.min(i, slides.length - 1));
1212
+ lastFocus = document.activeElement;
1213
+ prevOverflow = document.body.style.overflow;
1214
+ document.body.style.overflow = "hidden";
1215
+ lb.classList.add("open");
1216
+ render();
1217
+ btnClose.focus();
1218
+ }}
1219
+
1220
+ function close(){{
1221
+ lb.classList.remove("open");
1222
+ document.body.style.overflow = prevOverflow || "";
1223
+ if(lastFocus && lastFocus.focus) lastFocus.focus();
1224
+ }}
1225
+
1226
+ function move(d){{
1227
+ const ni = idx + d;
1228
+ if(ni < 0 || ni >= slides.length) return;
1229
+ idx = ni;
1230
+ render();
1231
+ }}
1232
+
1233
+ btnClose.addEventListener("click", close);
1234
+ lb.addEventListener("click", (e)=> {{ if(e.target === lb) close(); }});
1235
+ btnPrev.addEventListener("click", ()=> move(-1));
1236
+ btnNext.addEventListener("click", ()=> move(+1));
1237
+
1238
+ document.addEventListener("keydown", (e)=> {{
1239
+ if(!lb.classList.contains("open")) return;
1240
+ if(e.key === "Escape") return close();
1241
+ if(e.key === "ArrowLeft") return move(-1);
1242
+ if(e.key === "ArrowRight") return move(+1);
1243
+ }});
1244
+
1245
+ function ensureNav(sec, rail){{
1246
+ let wrap = sec.querySelector(".smx-gallery-wrap");
1247
+ if(!wrap){{
1248
+ wrap = document.createElement("div");
1249
+ wrap.className = "smx-gallery-wrap";
1250
+ rail.parentNode.insertBefore(wrap, rail);
1251
+ wrap.appendChild(rail);
1252
+ }}
1253
+
1254
+ let prev = wrap.querySelector("button.smx-gallery-nav.prev");
1255
+ let next = wrap.querySelector("button.smx-gallery-nav.next");
1256
+
1257
+ if(!prev){{
1258
+ prev = document.createElement("button");
1259
+ prev.className = "smx-gallery-nav prev";
1260
+ prev.type = "button";
1261
+ prev.setAttribute("aria-label","Scroll left");
1262
+ prev.innerHTML = "‹";
1263
+ wrap.appendChild(prev);
1264
+ }}
1265
+ if(!next){{
1266
+ next = document.createElement("button");
1267
+ next.className = "smx-gallery-nav next";
1268
+ next.type = "button";
1269
+ next.setAttribute("aria-label","Scroll right");
1270
+ next.innerHTML = "›";
1271
+ wrap.appendChild(next);
1272
+ }}
1273
+
1274
+ function update(){{
1275
+ const max = rail.scrollWidth - rail.clientWidth;
1276
+ const canScroll = max > 4;
1277
+ prev.style.display = canScroll ? "" : "none";
1278
+ next.style.display = canScroll ? "" : "none";
1279
+ prev.disabled = rail.scrollLeft <= 2;
1280
+ next.disabled = rail.scrollLeft >= (max - 2);
1281
+ }}
1282
+
1283
+ prev.addEventListener("click", ()=> {{
1284
+ if(prev.disabled) return;
1285
+ rail.scrollBy({{ left: -Math.max(260, rail.clientWidth * 0.85), behavior:"smooth" }});
1286
+ }});
1287
+ next.addEventListener("click", ()=> {{
1288
+ if(next.disabled) return;
1289
+ rail.scrollBy({{ left: Math.max(260, rail.clientWidth * 0.85), behavior:"smooth" }});
1290
+ }});
1291
+
1292
+ rail.addEventListener("scroll", update, {{ passive:true }});
1293
+ window.addEventListener("resize", update);
1294
+ update();
1295
+ }}
1296
+
1297
+ function initGallerySection(sec){{
1298
+ const rail = sec.querySelector(".grid");
1299
+ if(!rail) return;
1300
+
1301
+ ensureNav(sec, rail);
1302
+
1303
+ // Build slides from IMAGE tiles only (cards remain content cards)
1304
+ const tiles = Array.from(rail.querySelectorAll('[data-item-type="image"]'));
1305
+ if(!tiles.length) return;
1306
+
1307
+ // Build a local slide list for THIS rail
1308
+ const localSlides = [];
1309
+ tiles.forEach(tile => {{
1310
+ const img = tile.querySelector("img");
1311
+ if(!img) return;
1312
+
1313
+ const full = (tile.getAttribute("data-full") || img.getAttribute("data-full") || img.getAttribute("src") || "").trim();
1314
+ if(!full) return;
1315
+
1316
+ const title = (tile.getAttribute("data-title") || "").trim();
1317
+ const caption = (tile.getAttribute("data-caption") || "").trim();
1318
+
1319
+ const slideIndex = localSlides.length;
1320
+ localSlides.push({{ src: full, title, caption }});
1321
+
1322
+ tile.dataset.smxSlide = String(slideIndex);
1323
+
1324
+ // IMPORTANT: don’t double-bind (dynamic inserts / re-init safe)
1325
+ if(tile.dataset.smxBound === "1") return;
1326
+ tile.dataset.smxBound = "1";
1327
+
1328
+ tile.addEventListener("click", (e)=> {{
1329
+ if(e.target && e.target.closest && e.target.closest("a,button")) return;
1330
+ slides = localSlides; // activate the correct slide set for this rail
1331
+ const i = parseInt(tile.dataset.smxSlide || "0", 10);
1332
+ openAt(i);
1333
+ }});
1334
+ }});
1335
+ }}
1336
+
1337
+ gallerySecs.forEach(sec => initGallerySection(sec));
1338
+
1339
+ // Re-init when new gallery sections or new tiles are inserted dynamically (Admin editor)
1340
+ const obs = new MutationObserver((mutations) => {{
1341
+ for(const m of mutations){{
1342
+ if(m.type !== "childList") continue;
1343
+
1344
+ // If a gallery section is added, init it
1345
+ m.addedNodes && m.addedNodes.forEach(node => {{
1346
+ if(!(node instanceof Element)) return;
1347
+
1348
+ if(node.matches && node.matches('section[data-section-type="gallery"]')){{
1349
+ initGallerySection(node);
1350
+ return;
1351
+ }}
1352
+
1353
+ // If anything added inside/near a gallery section, init the nearest section
1354
+ const sec = node.closest ? node.closest('section[data-section-type="gallery"]') : null;
1355
+ if(sec) initGallerySection(sec);
1356
+ }});
1357
+ }}
1358
+ }});
1359
+
1360
+ obs.observe(root, {{ childList: true, subtree: true }});
1361
+
1362
+ }})();
1363
+ </script>
1364
+ """.strip()
1365
+
1366
+
834
1367
  def esc(s: str) -> str:
835
1368
  s = s or ""
836
1369
  s = s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
837
1370
  s = s.replace('"', "&quot;").replace("'", "&#39;")
838
1371
  return s
1372
+
1373
+ def esc_nl(s: str) -> str:
1374
+ # preserve paragraphs in plain text
1375
+ return esc(s).replace("\n", "<br>")
1376
+
839
1377
 
840
1378
  def icon_svg(name: str) -> str:
841
1379
  svg = _ICON_SVGS.get((name or "").strip().lower())
@@ -843,7 +1381,7 @@ def compile_layout_to_html(layout: Dict[str, Any], *, page_slug: str) -> str:
843
1381
  return ""
844
1382
  return f'<span class="icon">{svg}</span>'
845
1383
 
846
- parts: List[str] = [f'<div class="smxp" id="smxp-{page_id}">', css]
1384
+ parts: List[str] = [f'<div class="smxp" id="smxp-{page_id}">', css, gallery_css]
847
1385
  sections = layout.get("sections") if isinstance(layout.get("sections"), list) else []
848
1386
 
849
1387
  # Map first section id by type (used for default Hero CTA anchors)
@@ -869,7 +1407,7 @@ def compile_layout_to_html(layout: Dict[str, Any], *, page_slug: str) -> str:
869
1407
  for s in sections:
870
1408
  stype = (s.get("type") or "section").lower()
871
1409
  title = esc(s.get("title") or "")
872
- text = esc(s.get("text") or "")
1410
+ text = esc_nl(s.get("text") or "")
873
1411
  items = s.get("items") if isinstance(s.get("items"), list) else []
874
1412
  sec_dom_id = (s.get("id") or "").strip()
875
1413
  if not sec_dom_id:
@@ -913,10 +1451,10 @@ def compile_layout_to_html(layout: Dict[str, Any], *, page_slug: str) -> str:
913
1451
  )
914
1452
 
915
1453
  btn_row_html = f'<div class="btnRow">{"".join(btns)}</div>' if btns else ""
916
-
1454
+
917
1455
  parts.append(
918
1456
  f'''
919
- <section id="{esc(sec_dom_id)}" class="hero hero-banner">
1457
+ <section id="{esc(sec_dom_id)}" class="hero hero-banner" data-section-type="hero">
920
1458
  <div class="hero-bg"{bg_style}></div>
921
1459
  <div class="hero-overlay"></div>
922
1460
  <div class="wrap hero-content">
@@ -939,38 +1477,94 @@ def compile_layout_to_html(layout: Dict[str, Any], *, page_slug: str) -> str:
939
1477
  cols = 3
940
1478
  cols = max(1, min(5, cols))
941
1479
 
942
- cards: List[str] = []
1480
+ tiles: List[str] = []
943
1481
  for it in items:
944
1482
  if not isinstance(it, dict):
945
1483
  continue
946
- it_title = esc(it.get("title") or "")
947
- it_text = esc(it.get("text") or "")
948
- img = (it.get("imageUrl") or "").strip()
1484
+
1485
+ it_type = str(it.get("type") or "card").lower().strip()
1486
+
1487
+ title_plain = (it.get("title") or "").strip()
1488
+ cap_plain = (it.get("text") or "").strip()
1489
+
1490
+ it_title = esc(title_plain)
1491
+ it_text = esc_nl(cap_plain)
1492
+
1493
+ full = (it.get("imageUrl") or "").strip()
1494
+ thumb = (it.get("thumbUrl") or it.get("thumb_url") or "").strip()
1495
+ src = thumb or full # rail prefers thumb if available
1496
+
1497
+ # IMAGE tiles: true gallery behaviour (thumb in rail, full in lightbox)
1498
+ if stype == "gallery" and it_type == "image":
1499
+ if not src:
1500
+ continue
1501
+ full_for_lb = full or src
1502
+ tiles.append(
1503
+ f'''
1504
+ <figure class="gimg reveal"
1505
+ data-item-type="image"
1506
+ data-full="{esc(full_for_lb)}"
1507
+ data-title="{esc(title_plain)}"
1508
+ data-caption="{esc(cap_plain)}">
1509
+ <img loading="lazy" decoding="async"
1510
+ src="{esc(src)}"
1511
+ data-full="{esc(full_for_lb)}"
1512
+ alt="{esc(title_plain or 'image')}">
1513
+ {f'<figcaption>{it_title}</figcaption>' if title_plain else ''}
1514
+ </figure>
1515
+ '''.strip()
1516
+ )
1517
+ continue
1518
+
1519
+ # Default: render as a content card (Card, FAQ, Quote, etc.)
949
1520
  ic = icon_svg(it.get("icon") or "")
1521
+ img_html = f'<img loading="lazy" decoding="async" src="{esc(full)}" alt="{it_title}">' if full else ""
1522
+
1523
+ title_align = _safe_align(str(it.get("titleAlign") or ""))
1524
+ text_align = _safe_align(str(it.get("textAlign") or ""))
1525
+
1526
+ title_align_css = f"text-align:{title_align};" if title_align else ""
1527
+ text_align_css = f"text-align:{text_align};" if text_align else ""
950
1528
 
951
- img_html = f'<img loading="lazy" decoding="async" src="{esc(img)}" alt="{it_title}">' if img else ""
952
- cards.append(
1529
+ raw_html = str(it.get("textHtml") or "").strip()
1530
+ if raw_html:
1531
+ safe_html = _sanitize_rich_html(raw_html)
1532
+ body_html = f'<div class="smx-rich" style="{text_align_css}">{safe_html}</div>'
1533
+ else:
1534
+ body_html = f'<p style="margin-top:8px;{text_align_css}">{it_text}</p>'
1535
+
1536
+ tiles.append(
953
1537
  f'''
954
- <div class="card reveal">
1538
+ <div class="card reveal" data-item-type="{esc(it_type)}">
955
1539
  {img_html}
956
- <div style="display:flex;gap:10px;align-items:center;margin-top:{'10px' if img_html else '0'};">
1540
+ <div style="display:flex;gap:10px;align-items:flex-start;margin-top:{'10px' if img_html else '0'};">
957
1541
  {ic}
958
- <h3 style="margin:0">{it_title}</h3>
1542
+ <h3 style="margin:0;{title_align_css}">{it_title}</h3>
959
1543
  </div>
960
- <p style="margin-top:8px">{it_text}</p>
1544
+ {body_html}
961
1545
  </div>
962
1546
  '''.strip()
963
1547
  )
964
1548
 
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 ""
1549
+ if tiles:
1550
+ if stype == "gallery":
1551
+ grid_html = '<div class="grid">' + "\n".join(tiles) + "</div>"
1552
+ else:
1553
+ grid_html = (
1554
+ f'<div class="grid" style="grid-template-columns:repeat({cols}, minmax(0, 1fr));">'
1555
+ + "\n".join(tiles) +
1556
+ "</div>"
1557
+ )
1558
+ else:
1559
+ grid_html = ""
1560
+
1561
+
1562
+ sec_class = "sec sec-gallery" if stype == "gallery" else "sec"
1563
+ sec_extra_attr = ' data-section-type="gallery"' if stype == "gallery" else ""
970
1564
 
971
1565
  parts.append(
972
1566
  f'''
973
- <section id="{esc(sec_dom_id)}" class="sec">
1567
+ <section id="{esc(sec_dom_id)}" class="sec" data-section-type="{esc(stype or 'section')}">
974
1568
  <div class="wrap">
975
1569
  <h2 class="reveal">{title}</h2>
976
1570
  {'<p class="reveal" style="margin-bottom:14px;">'+text+'</p>' if text else ''}
@@ -981,12 +1575,11 @@ def compile_layout_to_html(layout: Dict[str, Any], *, page_slug: str) -> str:
981
1575
  )
982
1576
 
983
1577
  parts.append(js)
1578
+ parts.append(gallery_js)
984
1579
  parts.append("</div>")
985
- return "\n\n".join(parts)
986
1580
 
1581
+ return "\n\n".join(parts)
987
1582
 
988
- from bs4 import BeautifulSoup
989
- from typing import Dict, Any, List, Tuple
990
1583
 
991
1584
  def _layout_non_hero_sections(layout: Dict[str, Any]) -> List[Dict[str, Any]]:
992
1585
  sections = layout.get("sections") if isinstance(layout.get("sections"), list) else []