syntaxmatrix 2.5.5.5__py3-none-any.whl → 2.6.2__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 +3 -2
- syntaxmatrix/agentic/agents.py +1220 -169
- syntaxmatrix/agentic/agents_orchestrer.py +326 -0
- syntaxmatrix/agentic/code_tools_registry.py +27 -32
- syntaxmatrix/auth.py +142 -5
- syntaxmatrix/commentary.py +16 -16
- syntaxmatrix/core.py +192 -84
- syntaxmatrix/db.py +460 -4
- syntaxmatrix/{display.py → display_html.py} +2 -6
- syntaxmatrix/gpt_models_latest.py +1 -1
- syntaxmatrix/media/__init__.py +0 -0
- syntaxmatrix/media/media_pixabay.py +277 -0
- syntaxmatrix/models.py +1 -1
- syntaxmatrix/page_builder_defaults.py +183 -0
- syntaxmatrix/page_builder_generation.py +1122 -0
- syntaxmatrix/page_layout_contract.py +644 -0
- syntaxmatrix/page_patch_publish.py +1471 -0
- syntaxmatrix/preface.py +670 -0
- syntaxmatrix/profiles.py +28 -10
- syntaxmatrix/routes.py +1941 -593
- syntaxmatrix/selftest_page_templates.py +360 -0
- syntaxmatrix/settings/client_items.py +28 -0
- syntaxmatrix/settings/model_map.py +1022 -207
- syntaxmatrix/settings/prompts.py +328 -130
- syntaxmatrix/static/assets/hero-default.svg +22 -0
- syntaxmatrix/static/icons/bot-icon.png +0 -0
- syntaxmatrix/static/icons/favicon.png +0 -0
- syntaxmatrix/static/icons/logo.png +0 -0
- syntaxmatrix/static/icons/logo3.png +0 -0
- syntaxmatrix/templates/admin_branding.html +104 -0
- syntaxmatrix/templates/admin_features.html +63 -0
- syntaxmatrix/templates/admin_secretes.html +108 -0
- syntaxmatrix/templates/change_password.html +124 -0
- syntaxmatrix/templates/dashboard.html +296 -131
- syntaxmatrix/templates/dataset_resize.html +535 -0
- syntaxmatrix/templates/edit_page.html +2535 -0
- syntaxmatrix/utils.py +2728 -2835
- {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/METADATA +6 -2
- {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/RECORD +42 -25
- syntaxmatrix/generate_page.py +0 -634
- syntaxmatrix/static/icons/hero_bg.jpg +0 -0
- {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/WHEEL +0 -0
- {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/licenses/LICENSE.txt +0 -0
- {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import io
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
from PIL import Image
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
PIXABAY_API_URL = "https://pixabay.com/api/"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class PixabayHit:
|
|
19
|
+
id: int
|
|
20
|
+
page_url: str
|
|
21
|
+
tags: str
|
|
22
|
+
user: str
|
|
23
|
+
preview_url: str
|
|
24
|
+
webformat_url: str
|
|
25
|
+
large_image_url: str
|
|
26
|
+
width: int
|
|
27
|
+
height: int
|
|
28
|
+
image_type: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _clean_query(q: str) -> str:
|
|
32
|
+
q = (q or "").strip()
|
|
33
|
+
q = re.sub(r"\s+", " ", q)
|
|
34
|
+
return q[:100]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def pixabay_search(
|
|
38
|
+
api_key: str,
|
|
39
|
+
query: str,
|
|
40
|
+
*,
|
|
41
|
+
image_type: str = "photo",
|
|
42
|
+
orientation: str = "horizontal",
|
|
43
|
+
per_page: int = 24,
|
|
44
|
+
page: int = 1,
|
|
45
|
+
safesearch: bool = True,
|
|
46
|
+
editors_choice: bool = False,
|
|
47
|
+
min_width: int = 0,
|
|
48
|
+
min_height: int = 0,
|
|
49
|
+
timeout: int = 15,
|
|
50
|
+
) -> List[PixabayHit]:
|
|
51
|
+
"""Search Pixabay and return normalised hits."""
|
|
52
|
+
if not api_key:
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
q = _clean_query(query)
|
|
56
|
+
if not q:
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
per_page = max(3, min(200, int(per_page or 24)))
|
|
60
|
+
page = max(1, int(page or 1))
|
|
61
|
+
|
|
62
|
+
params = {
|
|
63
|
+
"key": api_key,
|
|
64
|
+
"q": q,
|
|
65
|
+
"image_type": image_type or "photo",
|
|
66
|
+
"orientation": orientation or "all",
|
|
67
|
+
"per_page": per_page,
|
|
68
|
+
"page": page,
|
|
69
|
+
"safesearch": "true" if safesearch else "false",
|
|
70
|
+
"editors_choice": "true" if editors_choice else "false",
|
|
71
|
+
"min_width": int(min_width or 0),
|
|
72
|
+
"min_height": int(min_height or 0),
|
|
73
|
+
"order": "popular",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
r = requests.get(PIXABAY_API_URL, params=params, timeout=timeout)
|
|
77
|
+
r.raise_for_status()
|
|
78
|
+
data = r.json() or {}
|
|
79
|
+
hits = data.get("hits") or []
|
|
80
|
+
|
|
81
|
+
out: List[PixabayHit] = []
|
|
82
|
+
for h in hits:
|
|
83
|
+
try:
|
|
84
|
+
out.append(
|
|
85
|
+
PixabayHit(
|
|
86
|
+
id=int(h.get("id")),
|
|
87
|
+
page_url=str(h.get("pageURL") or ""),
|
|
88
|
+
tags=str(h.get("tags") or ""),
|
|
89
|
+
user=str(h.get("user") or ""),
|
|
90
|
+
preview_url=str(h.get("previewURL") or ""),
|
|
91
|
+
webformat_url=str(h.get("webformatURL") or ""),
|
|
92
|
+
large_image_url=str(h.get("largeImageURL") or ""),
|
|
93
|
+
width=int(h.get("imageWidth") or 0),
|
|
94
|
+
height=int(h.get("imageHeight") or 0),
|
|
95
|
+
image_type=str(h.get("type") or image_type or "photo"),
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
except Exception:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _is_pixabay_url(url: str) -> bool:
|
|
105
|
+
url = (url or "").strip().lower()
|
|
106
|
+
return url.startswith("https://") and ("pixabay.com" in url)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _sha256_bytes(b: bytes) -> str:
|
|
110
|
+
return hashlib.sha256(b).hexdigest()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _dhash_hex(img: Image.Image, size: int = 8) -> str:
|
|
114
|
+
g = img.convert("L").resize((size + 1, size), Image.LANCZOS)
|
|
115
|
+
px = list(g.getdata())
|
|
116
|
+
rows = [px[i * (size + 1):(i + 1) * (size + 1)] for i in range(size)]
|
|
117
|
+
bits = []
|
|
118
|
+
for row in rows:
|
|
119
|
+
for x in range(size):
|
|
120
|
+
bits.append(1 if row[x] > row[x + 1] else 0)
|
|
121
|
+
|
|
122
|
+
val = 0
|
|
123
|
+
for b in bits:
|
|
124
|
+
val = (val << 1) | b
|
|
125
|
+
return f"{val:0{size*size//4}x}"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def download_and_store_image(
|
|
129
|
+
url: str,
|
|
130
|
+
*,
|
|
131
|
+
out_path_no_ext: str,
|
|
132
|
+
max_width: int = 1920,
|
|
133
|
+
thumb_dir: Optional[str] = None,
|
|
134
|
+
thumb_width: int = 800,
|
|
135
|
+
timeout: int = 20,
|
|
136
|
+
min_width: int = 0,
|
|
137
|
+
fallback_url: Optional[str] = None,
|
|
138
|
+
) -> Dict[str, Any]:
|
|
139
|
+
"""
|
|
140
|
+
Downloads an image from Pixabay.
|
|
141
|
+
|
|
142
|
+
Rule:
|
|
143
|
+
- Always try `url` first (webformat).
|
|
144
|
+
- If `min_width` is set and the downloaded image is narrower than min_width,
|
|
145
|
+
then try `fallback_url` (large) once, and keep it only if it's larger.
|
|
146
|
+
- Then resize down to max_width (1920).
|
|
147
|
+
- Save as JPG unless image has alpha channel (then PNG).
|
|
148
|
+
- Create thumbnail only if width > 800px (your rule).
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def _fetch(u: str) -> bytes:
|
|
152
|
+
if not _is_pixabay_url(u):
|
|
153
|
+
raise ValueError("Only Pixabay URLs are allowed")
|
|
154
|
+
rr = requests.get(u, stream=True, timeout=timeout)
|
|
155
|
+
rr.raise_for_status()
|
|
156
|
+
return rr.content
|
|
157
|
+
|
|
158
|
+
# 1) Fetch webformat first
|
|
159
|
+
content = _fetch(url)
|
|
160
|
+
img = Image.open(io.BytesIO(content))
|
|
161
|
+
img.load()
|
|
162
|
+
|
|
163
|
+
# 2) If too small (e.g., hero) try large variant once
|
|
164
|
+
if min_width and img.width < int(min_width) and fallback_url:
|
|
165
|
+
try:
|
|
166
|
+
content2 = _fetch(fallback_url)
|
|
167
|
+
img2 = Image.open(io.BytesIO(content2))
|
|
168
|
+
img2.load()
|
|
169
|
+
if img2.width > img.width:
|
|
170
|
+
content = content2
|
|
171
|
+
img = img2
|
|
172
|
+
except Exception:
|
|
173
|
+
# If large fetch fails, keep webformat result
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
# 3) Resize down to max_width (never upsample)
|
|
177
|
+
if img.width > int(max_width or 1920):
|
|
178
|
+
ratio = (int(max_width) / float(img.width))
|
|
179
|
+
new_h = max(1, int(round(img.height * ratio)))
|
|
180
|
+
img = img.resize((int(max_width), new_h), Image.LANCZOS)
|
|
181
|
+
|
|
182
|
+
# 4) Decide output format
|
|
183
|
+
has_alpha = ("A" in img.getbands())
|
|
184
|
+
ext = ".png" if has_alpha else ".jpg"
|
|
185
|
+
out_path = out_path_no_ext + ext
|
|
186
|
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
|
187
|
+
|
|
188
|
+
# 5) Encode + save
|
|
189
|
+
buf = io.BytesIO()
|
|
190
|
+
if ext == ".jpg":
|
|
191
|
+
rgb = img.convert("RGB") if img.mode != "RGB" else img
|
|
192
|
+
rgb.save(buf, "JPEG", quality=85, optimize=True, progressive=True)
|
|
193
|
+
mime = "image/jpeg"
|
|
194
|
+
else:
|
|
195
|
+
img.save(buf, "PNG", optimize=True)
|
|
196
|
+
mime = "image/png"
|
|
197
|
+
|
|
198
|
+
data = buf.getvalue()
|
|
199
|
+
with open(out_path, "wb") as f:
|
|
200
|
+
f.write(data)
|
|
201
|
+
|
|
202
|
+
meta = {
|
|
203
|
+
"file_path": out_path,
|
|
204
|
+
"width": img.width,
|
|
205
|
+
"height": img.height,
|
|
206
|
+
"sha256": _sha256_bytes(data),
|
|
207
|
+
"dhash": _dhash_hex(img),
|
|
208
|
+
"mime": mime,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
# 6) Thumbnail only if width > 800px (your rule)
|
|
212
|
+
if thumb_dir and img.width > int(thumb_width or 800):
|
|
213
|
+
os.makedirs(thumb_dir, exist_ok=True)
|
|
214
|
+
thumb = img.copy()
|
|
215
|
+
ratio = (int(thumb_width) / float(thumb.width))
|
|
216
|
+
new_h = max(1, int(round(thumb.height * ratio)))
|
|
217
|
+
thumb = thumb.resize((int(thumb_width), new_h), Image.LANCZOS)
|
|
218
|
+
|
|
219
|
+
thumb_path = os.path.join(
|
|
220
|
+
thumb_dir,
|
|
221
|
+
os.path.basename(out_path_no_ext) + f"-t{int(thumb_width)}.jpg"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
tb = io.BytesIO()
|
|
225
|
+
thumb_rgb = thumb.convert("RGB") if thumb.mode != "RGB" else thumb
|
|
226
|
+
thumb_rgb.save(tb, "JPEG", quality=82, optimize=True, progressive=True)
|
|
227
|
+
with open(thumb_path, "wb") as f:
|
|
228
|
+
f.write(tb.getvalue())
|
|
229
|
+
|
|
230
|
+
meta["thumb_path"] = thumb_path
|
|
231
|
+
|
|
232
|
+
return meta
|
|
233
|
+
|
|
234
|
+
def import_pixabay_hit(
|
|
235
|
+
hit: PixabayHit,
|
|
236
|
+
*,
|
|
237
|
+
media_images_dir: str,
|
|
238
|
+
thumbs_dir: Optional[str] = None,
|
|
239
|
+
max_width: int = 1920,
|
|
240
|
+
thumb_width: int = 800,
|
|
241
|
+
min_width: int = 0,
|
|
242
|
+
) -> Dict[str, Any]:
|
|
243
|
+
# Efficient by default: use webformat first.
|
|
244
|
+
url = hit.webformat_url or hit.large_image_url
|
|
245
|
+
if not url:
|
|
246
|
+
raise ValueError("No image url")
|
|
247
|
+
|
|
248
|
+
if not _is_pixabay_url(url):
|
|
249
|
+
raise ValueError("Only Pixabay URLs are allowed")
|
|
250
|
+
|
|
251
|
+
# Only use large as fallback when webformat is too small for the use-case (e.g., hero)
|
|
252
|
+
fallback = None
|
|
253
|
+
if url == hit.webformat_url and hit.large_image_url and hit.large_image_url != hit.webformat_url:
|
|
254
|
+
fallback = hit.large_image_url
|
|
255
|
+
|
|
256
|
+
out_base = os.path.join(media_images_dir, f"pixabay-{hit.id}")
|
|
257
|
+
|
|
258
|
+
meta = download_and_store_image(
|
|
259
|
+
url,
|
|
260
|
+
out_path_no_ext=out_base,
|
|
261
|
+
max_width=max_width,
|
|
262
|
+
thumb_dir=thumbs_dir,
|
|
263
|
+
thumb_width=thumb_width,
|
|
264
|
+
min_width=min_width,
|
|
265
|
+
fallback_url=fallback,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
meta.update(
|
|
269
|
+
{
|
|
270
|
+
"source": "pixabay",
|
|
271
|
+
"source_url": hit.page_url,
|
|
272
|
+
"author": hit.user,
|
|
273
|
+
"tags": hit.tags,
|
|
274
|
+
"pixabay_id": hit.id,
|
|
275
|
+
}
|
|
276
|
+
)
|
|
277
|
+
return meta
|
syntaxmatrix/models.py
CHANGED
|
@@ -8,7 +8,7 @@ class Workspace(db.Model):
|
|
|
8
8
|
name = db.Column(db.String(64), unique=True, nullable=False)
|
|
9
9
|
llm_provider = db.Column(db.String(24), default="openai")
|
|
10
10
|
llm_model = db.Column(db.String(48), default="gpt-3.5-turbo")
|
|
11
|
-
llm_api_key = db.Column(db.LargeBinary)
|
|
11
|
+
llm_api_key = db.Column(db.LargeBinary)
|
|
12
12
|
|
|
13
13
|
def __repr__(self):
|
|
14
14
|
return f"<Workspace {self.name}>"
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html as _html
|
|
4
|
+
import re as _re
|
|
5
|
+
from typing import Any, Dict, List
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _title_from_slug(slug: str) -> str:
|
|
9
|
+
s = (slug or "").strip().replace("_", " ").replace("-", " ")
|
|
10
|
+
s = _re.sub(r"\s+", " ", s).strip()
|
|
11
|
+
if not s:
|
|
12
|
+
return "New page"
|
|
13
|
+
return s[:1].upper() + s[1:]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def make_default_layout(page_name: str, website_description: str = "") -> Dict[str, Any]:
|
|
17
|
+
"""Create a starter layout JSON for the drag-and-drop page builder."""
|
|
18
|
+
page = (page_name or "page").strip().lower()
|
|
19
|
+
desc = (website_description or "").strip()
|
|
20
|
+
|
|
21
|
+
def sec(_id: str, _type: str, title: str, text: str, cols: int, items: List[Dict[str, Any]]):
|
|
22
|
+
return {
|
|
23
|
+
"id": _id,
|
|
24
|
+
"type": _type,
|
|
25
|
+
"title": title,
|
|
26
|
+
"text": text,
|
|
27
|
+
"cols": cols,
|
|
28
|
+
"items": items or [],
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
def item(_id: str, _type: str, title: str, text: str, image_url: str = ""):
|
|
32
|
+
return {
|
|
33
|
+
"id": _id,
|
|
34
|
+
"type": _type,
|
|
35
|
+
"title": title,
|
|
36
|
+
"text": text,
|
|
37
|
+
"imageUrl": image_url or "",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
hero_title = _title_from_slug(page)
|
|
41
|
+
hero_text = desc or "Describe your offering here. Replace this text with your own message."
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
"page": page,
|
|
45
|
+
"sections": [
|
|
46
|
+
sec(
|
|
47
|
+
"sec_hero",
|
|
48
|
+
"hero",
|
|
49
|
+
hero_title,
|
|
50
|
+
hero_text,
|
|
51
|
+
1,
|
|
52
|
+
[
|
|
53
|
+
item("item_hero_1", "card", "Get started", "Add a short call-to-action here.", ""),
|
|
54
|
+
],
|
|
55
|
+
),
|
|
56
|
+
sec(
|
|
57
|
+
"sec_features",
|
|
58
|
+
"features",
|
|
59
|
+
"Features",
|
|
60
|
+
"Three quick reasons people choose you.",
|
|
61
|
+
3,
|
|
62
|
+
[
|
|
63
|
+
item("item_feat_1", "card", "Fast setup", "Be up and running quickly.", ""),
|
|
64
|
+
item("item_feat_2", "card", "Secure by design", "Built with sensible defaults.", ""),
|
|
65
|
+
item("item_feat_3", "card", "Support that cares", "We help you succeed.", ""),
|
|
66
|
+
],
|
|
67
|
+
),
|
|
68
|
+
sec(
|
|
69
|
+
"sec_gallery",
|
|
70
|
+
"gallery",
|
|
71
|
+
"Gallery",
|
|
72
|
+
"Add images that show your product, team, or work.",
|
|
73
|
+
3,
|
|
74
|
+
[
|
|
75
|
+
item("item_gal_1", "card", "Image", "Drop an image onto this card.", ""),
|
|
76
|
+
item("item_gal_2", "card", "Image", "Drop an image onto this card.", ""),
|
|
77
|
+
item("item_gal_3", "card", "Image", "Drop an image onto this card.", ""),
|
|
78
|
+
],
|
|
79
|
+
),
|
|
80
|
+
sec(
|
|
81
|
+
"sec_testimonials",
|
|
82
|
+
"testimonials",
|
|
83
|
+
"Testimonials",
|
|
84
|
+
"A couple of short quotes from customers.",
|
|
85
|
+
2,
|
|
86
|
+
[
|
|
87
|
+
item("item_test_1", "quote", "Customer name", "“A short customer quote goes here.”", ""),
|
|
88
|
+
item("item_test_2", "quote", "Customer name", "“Another short quote goes here.”", ""),
|
|
89
|
+
],
|
|
90
|
+
),
|
|
91
|
+
sec(
|
|
92
|
+
"sec_faq",
|
|
93
|
+
"faq",
|
|
94
|
+
"FAQ",
|
|
95
|
+
"Answer the most common questions upfront.",
|
|
96
|
+
2,
|
|
97
|
+
[
|
|
98
|
+
item("item_faq_1", "faq", "What do you offer?", "Explain in one or two sentences.", ""),
|
|
99
|
+
item("item_faq_2", "faq", "How do I get started?", "Tell them the first step.", ""),
|
|
100
|
+
],
|
|
101
|
+
),
|
|
102
|
+
sec(
|
|
103
|
+
"sec_cta",
|
|
104
|
+
"cta",
|
|
105
|
+
"Ready to talk?",
|
|
106
|
+
"Add a clear call-to-action with next steps.",
|
|
107
|
+
2,
|
|
108
|
+
[
|
|
109
|
+
item("item_cta_1", "card", "Book a demo", "Invite people to contact you.", ""),
|
|
110
|
+
item("item_cta_2", "card", "Email us", "Add your preferred contact method.", ""),
|
|
111
|
+
],
|
|
112
|
+
),
|
|
113
|
+
],
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _esc(s: str) -> str:
|
|
118
|
+
return _html.escape(s or "", quote=True)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def layout_to_html(st: Dict[str, Any]) -> str:
|
|
122
|
+
"""Render layout JSON into HTML snippets used by /page/<page_name>."""
|
|
123
|
+
sections = st.get("sections") if isinstance(st, dict) else None
|
|
124
|
+
if not isinstance(sections, list):
|
|
125
|
+
return ""
|
|
126
|
+
|
|
127
|
+
blocks: List[str] = []
|
|
128
|
+
|
|
129
|
+
for s in sections:
|
|
130
|
+
if not isinstance(s, dict):
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
title = _esc(s.get("title") or "")
|
|
134
|
+
text = _esc(s.get("text") or "")
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
cols = int(s.get("cols") or 1)
|
|
138
|
+
except Exception:
|
|
139
|
+
cols = 1
|
|
140
|
+
cols = max(1, min(5, cols))
|
|
141
|
+
|
|
142
|
+
items_html: List[str] = []
|
|
143
|
+
for it in (s.get("items") or []):
|
|
144
|
+
if not isinstance(it, dict):
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
it_title = _esc(it.get("title") or "")
|
|
148
|
+
it_text = _esc(it.get("text") or "")
|
|
149
|
+
img = (it.get("imageUrl") or "").strip()
|
|
150
|
+
|
|
151
|
+
img_html = (
|
|
152
|
+
f'<img src="{_esc(img)}" alt="{it_title}" style="width:100%;height:auto;border-radius:12px;">'
|
|
153
|
+
if img else ""
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
items_html.append(
|
|
157
|
+
'<div style="border:1px solid rgba(148,163,184,.25);border-radius:16px;'
|
|
158
|
+
'padding:14px;background:rgba(15,23,42,.35);">'
|
|
159
|
+
f'{img_html}'
|
|
160
|
+
f'<h3 style="margin:10px 0 6px;font-size:1.05rem;">{it_title}</h3>'
|
|
161
|
+
f'<p style="margin:0;color:#cbd5e1;line-height:1.5;">{it_text}</p>'
|
|
162
|
+
'</div>'
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
grid = ""
|
|
166
|
+
if items_html:
|
|
167
|
+
joined = "\n".join(items_html)
|
|
168
|
+
grid = (
|
|
169
|
+
f'<div style="display:grid;gap:12px;grid-template-columns:repeat({cols}, minmax(0,1fr));">'
|
|
170
|
+
f'{joined}</div>'
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
p = f'<p style="margin:0 0 14px;color:#cbd5e1;line-height:1.55;">{text}</p>' if text else ""
|
|
174
|
+
|
|
175
|
+
blocks.append(
|
|
176
|
+
'<section style="max-width:1100px;margin:22px auto;padding:0 14px;">'
|
|
177
|
+
f'<h2 style="margin:0 0 8px;font-size:1.6rem;">{title}</h2>'
|
|
178
|
+
f'{p}'
|
|
179
|
+
f'{grid}'
|
|
180
|
+
'</section>'
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
return "\n\n".join(blocks)
|