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