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
|
@@ -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{
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
|
|
813
|
-
.smxp .hero-panel{
|
|
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("&", "&").replace("<", "<").replace(">", ">")
|
|
837
1381
|
s = s.replace('"', """).replace("'", "'")
|
|
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 =
|
|
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
|
-
|
|
1491
|
+
tiles: List[str] = []
|
|
943
1492
|
for it in items:
|
|
944
1493
|
if not isinstance(it, dict):
|
|
945
1494
|
continue
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
-
|
|
952
|
-
|
|
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:
|
|
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
|
-
|
|
1555
|
+
{body_html}
|
|
961
1556
|
</div>
|
|
962
1557
|
'''.strip()
|
|
963
1558
|
)
|
|
964
1559
|
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
-
|
|
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 []
|