Files
浅念 df2a183dbf upgrade: adopt V8.34.1 (multi-image banner carousel + spec-table source modes)
Plain overlay (33 items, same-source lineage, zero patch replay).
Theme suites 35/35; full run 453 passed + 10 known Windows env fails
(suspect-module rerun confirms identical failure set).
Live: container 8.34.1-dev.1; hesupipe home leftover industrial-preset
blocks cleared via studio draft+publish flow — home now renders pure
trade-pro template (sf-section/Chinese blocks = 0).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-26 18:03:07 +08:00

415 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Declarative theme/page blueprints, editor schema, and safe content bindings.
No eval and no arbitrary template code can be submitted by visitors.
"""
import copy
import json
import re
from html import escape
from urllib.parse import urlparse
from .core import sanitize, route_path
BLOCKS = {
'hero': {'name':'封面横幅','icon':'✦','fields':['title','subtitle','description','image','button','href','align','layout_variant','image_fit','slides'], 'defaults':{'title':'让品牌从这里出发','subtitle':'GLOBAL EXPERIENCE','description':'在这里呈现企业最重要的价值。','button':'探索产品','href':'/product/','align':'left','layout_variant':'split','image_fit':'cover','slides':[]}},
'richtext':{'name':'富文本','icon':'≡','fields':['title','body'],'defaults':{'title':'关于我们','body':'<p>在这里输入图文内容。</p>'}},
'features':{'name':'核心优势','icon':'▦','fields':['title','items'],'defaults':{'title':'我们的优势','items':[{'title':'专业服务','text':'从咨询到交付'},{'title':'品质保障','text':'严格的质量流程'},{'title':'全球网络','text':'连接不同市场'}]}},
'stats':{'name':'数字指标','icon':'#','fields':['title','items'],'defaults':{'title':'我们的实力','items':[{'value':'50,000+','label':'服务案例'},{'value':'70+','label':'市场覆盖'},{'value':'30+','label':'合作伙伴'}]}},
'collection':{'name':'动态内容列表','icon':'▣','fields':['title','subtitle','source','limit','columns','button','href','card_style','image_fit'],'defaults':{'title':'精选产品','subtitle':'为客户提供适合的解决方案','source':'product','limit':6,'columns':3,'button':'查看全部','href':'/product/','card_style':'standard','image_fit':'contain'}},
'image_text':{'name':'图文组合','icon':'◧','fields':['title','subtitle','description','image','image_side','button','href'],'defaults':{'title':'从采购到交付','subtitle':'OUR APPROACH','description':'展示业务流程和服务理念。','image':'','image_side':'right','button':'了解更多','href':'/about/'}},
'gallery':{'name':'图片画廊','icon':'▧','fields':['title','images','columns'],'defaults':{'title':'精彩瞬间','images':[],'columns':3}},
'faq':{'name':'常见问题','icon':'?','fields':['title','items'],'defaults':{'title':'常见问题','items':[{'question':'如何开始合作?','answer':'您可以通过联系我们页面提交需求。'}]}},
'cta':{'name':'行动号召','icon':'↗','fields':['title','subtitle','button','href'],'defaults':{'title':'开启新的合作','subtitle':'告诉我们您的需求,我们将提供适合的方案。','button':'联系我们','href':'/contact/'}},
'attributes':{'name':'参数信息表','icon':'▤','fields':['title','source_mode','items'],'defaults':{'title':'产品参数','source_mode':'page','items':[]}},
'layout':{'name':'多列自由布局','icon':'▥','fields':['columns','gap','tablet_columns','mobile_columns'], 'defaults':{'columns':[{'width':1,'blocks':[]},{'width':1,'blocks':[]}],'gap':24,'tablet_columns':2,'mobile_columns':1}},
'spacer':{'name':'上下留白','icon':'↕','fields':['height'],'defaults':{'height':48}},
'carousel':{'name':'图片轮播','icon':'▧','fields':['title','items'],'defaults':{'title':'精选展示','items':[{'image':'','alt':'展示图片','caption':'图片说明'}]}},
'video':{'name':'视频展示','icon':'▷','fields':['title','url','description'],'defaults':{'title':'企业影片','url':'','description':''}},
'tabs':{'name':'标签内容','icon':'☷','fields':['title','items'],'defaults':{'title':'详细信息','items':[{'label':'特点','text':'在这里介绍产品特点'},{'label':'服务','text':'在这里介绍服务'}]}},
'pricing':{'name':'服务方案','icon':'$','fields':['title','items'],'defaults':{'title':'服务方案','items':[{'name':'标准方案','price':'联系我们','features':['专业咨询','售后服务'],'href':'/contact/'}]}},
'map':{'name':'位置导航','icon':'⌖','fields':['title','address','url'],'defaults':{'title':'欢迎来访','address':'请填写实际办公地址','url':''}},
'form_link':{'name':'询盘表单入口','icon':'✉','fields':['title','description','button','href'],'defaults':{'title':'提交询盘','description':'欢迎联系我们获取报价和资料','button':'联系销售','href':'/contact/'}},
}
# Property contracts shared with the no-code editor. Unknown executable types
# are not supported; all values are still verified by validate_blocks.
BLOCK_PROPERTY_SCHEMA={key:{
'type':'object','additionalProperties':False,
'properties':{field:{'type':('array' if field in ('items','images','columns','slides') else
'integer' if field in ('height','limit','gap','tablet_columns','mobile_columns') else
'string')} for field in spec['fields']},
'supported_devices':['desktop','tablet','mobile'],
'allows_arbitrary_code':False,
} for key,spec in BLOCKS.items()}
BINDABLE = ['site.name','site.tagline','site.logo','page.title','page.summary','page.cover','page.body']
CONTENT_MODELS={
'product': [{'name':'brand','label':'品牌','type':'text'},{'name':'model','label':'型号','type':'text'},{'name':'category','label':'分类','type':'text'},{'name':'specs','label':'技术参数','type':'textarea'}],
'vehicle':[{'name':'brand','label':'品牌','type':'text'},{'name':'energy','label':'能源类型','type':'text'},{'name':'range','label':'续航里程','type':'text'}],
'post':[{'name':'author','label':'作者','type':'text'},{'name':'publish_date','label':'发布日期','type':'date'}],
'case':[{'name':'location','label':'项目地点','type':'text'},{'name':'industry','label':'行业','type':'text'}],
'brand':[{'name':'country','label':'所属地区','type':'text'},{'name':'website','label':'官方网站','type':'text'}]
}
PAGE_TEMPLATES={
'home':{'name':'首页','description':'全站形象与动态内容展示'},
'about':{'name':'关于我们','description':'企业介绍、优势与里程碑'},
'contact':{'name':'联系我们','description':'联系方式与业务咨询'},
'listing':{'name':'产品或文章列表','description':'内容查询、列表与分类展示'},
'product':{'name':'产品详情','description':'产品简介、图片、技术参数'},
'article':{'name':'新闻文章','description':'正文、作者与相关内容'},
'brand':{'name':'品牌详情','description':'品牌介绍、品牌产品'},
'standard':{'name':'标准内容页','description':'自由组合的自定义页面'},
'notfound':{'name':'404','description':'访问异常页面'},
'category':{'name':'分类详情','description':'按具体分类查看内容'},
'case':{'name':'案例详情','description':'项目介绍、项目图片和成果'},
'service':{'name':'服务详情','description':'业务服务介绍'},
'faq':{'name':'常见问题','description':'问题与回答'},
}
def sample_blocks(kind):
presets={
'home':[('hero',{}),('stats',{}),('collection',{}),('features',{}),('cta',{})],
'about':[('image_text',{'title':'认识我们'}),('stats',{}),('features',{})],
'contact':[('richtext',{'title':'联系我们','body':'<p>请填写企业联系方式与地址。</p>'}),('cta',{'title':'欢迎联系'})],
'listing':[('collection',{'title':'所有产品','limit':24})],
'product':[('image_text',{'title':'产品介绍','description':'{{page.summary}}','image':'{{page.cover}}'}),('attributes',{'title':'技术参数'}),('richtext',{'title':'产品详情','body':'{{page.body}}'}),('cta',{})],
'article':[('richtext',{'title':'{{page.title}}','body':'{{page.body}}'})],
'brand':[('image_text',{'title':'{{page.title}}','description':'{{page.summary}}','image':'{{page.cover}}'}),('collection',{'title':'品牌产品','source':'product'})],
'standard':[('richtext',{'title':'{{page.title}}','body':'{{page.body}}'})],
'notfound':[('cta',{'title':'页面暂时无法访问','href':'/','button':'返回首页'})],
'category':[('collection',{'source':'product','limit':24})],
'case':[('image_text',{'title':'{{page.title}}','image':'{{page.cover}}','description':'{{page.summary}}'}),('richtext',{'title':'项目介绍','body':'{{page.body}}'})],
'service':[('richtext',{'title':'{{page.title}}','body':'{{page.body}}'}),('features',{})],
'faq':[('faq',{})]
}
return [new_block(k,overrides) for k,overrides in presets.get(kind,presets['standard'])]
def new_block(kind, overrides=None):
import uuid
if kind not in BLOCKS:raise ValueError('未知区块类型: '+str(kind))
return {'id':uuid.uuid4().hex[:10], 'type':kind, 'visible':True, **copy.deepcopy(BLOCKS[kind]['defaults']), **(overrides or {})}
def validate_blocks(blocks):
"""Validate nested no-code rows; cap depth, blocks and payload size.
Children must always be safe declared block types. No arbitrary HTML or JS.
"""
found=set()
total=[0]
def normalize(rows,depth=0):
if not isinstance(rows,list) or len(rows)>60:raise ValueError('区块必须是数组,最多 60 个')
clean=[]
for block in rows:
if not isinstance(block,dict) or block.get('type') not in BLOCKS:raise ValueError('包含不支持的区块类型')
import uuid
total[0]+=1
if total[0]>120:raise ValueError('页面最多 120 个区块(含列内组件)')
ident=str(block.get('id') or uuid.uuid4().hex[:10]).strip()
if not re.fullmatch('[a-zA-Z0-9_-]{4,50}',ident) or ident in found:raise ValueError('区块 ID 缺失或重复')
found.add(ident)
spec=BLOCKS[block['type']]
visual_options={
'hero':{'layout_variant':('split','image-first','centered'),'image_fit':('cover','contain')},
'collection':{'card_style':('standard','compact','showcase'),'image_fit':('cover','contain')},
}
for visual_key, choices in visual_options.get(block['type'],{}).items():
if visual_key in block and (not isinstance(block[visual_key],str) or block[visual_key] not in choices):
raise ValueError('不支持的区块展示选项: '+visual_key)
# V8.34.1: strict item schemas. Never trust arbitrary HTML/URLs in slides.
if block['type']=='hero' and 'slides' in block:
slides=block['slides']
if not isinstance(slides,list) or len(slides)>6:
raise ValueError('首页横幅最多支持6张')
fields=('title','subtitle','description','image','button','href')
for slide in slides:
if not isinstance(slide,dict) or set(slide)-set(fields):
raise ValueError('横幅内容字段无效')
if any(not isinstance(v,str) or len(v)>2000 for v in slide.values()):
raise ValueError('横幅文字或图片地址无效')
if block['type']=='attributes':
mode=block.get('source_mode','page')
if not isinstance(mode,str) or mode not in ('page','custom','merge'):
raise ValueError('参数来源方式无效')
items=block.get('items',[])
if not isinstance(items,list) or len(items)>30:
raise ValueError('参数最多支持30项')
for item in items:
if (not isinstance(item,dict) or set(item)!={'label','value'} or
any(not isinstance(item[k],str) or len(item[k])>300 for k in ('label','value'))):
raise ValueError('参数项必须包含名称和数值')
allowed=set(spec['fields'])|{'id','type','visible','style','shared_ref'}
normalized={key:val for key,val in block.items() if key in allowed}
normalized['id']=ident
if 'shared_ref' in normalized:
ref=normalized['shared_ref']
if not isinstance(ref,dict) or set(ref)!={'id','version','hash'} or not isinstance(ref['id'],str) or not re.fullmatch(r'[a-zA-Z0-9_-]{8,64}',ref['id']) or type(ref['version']) is not int or not 1<=ref['version']<=1000000 or not isinstance(ref['hash'],str) or not re.fullmatch('[a-f0-9]{64}',ref['hash']):
raise ValueError('无效的公共组件引用')
normalized['shared_ref']={'id':ref['id'],'version':ref['version'],'hash':ref['hash']}
if 'style' in normalized:
style=normalized['style']
if not isinstance(style,dict):raise ValueError('样式必须为对象')
normalized['style']={}
for key,val in style.items():
if key in ('background','text_color'):
if not isinstance(val,str) or not re.fullmatch(r'#[0-9a-fA-F]{6}',val):raise ValueError('颜色必须为六位十六进制')
normalized['style'][key]=val
elif key in ('padding','tablet_padding','mobile_padding'):
if type(val) is not int or not 0<=val<=120:raise ValueError('区块间距必须是0-120整数')
normalized['style'][key]=val
elif key in ('hide_mobile','hide_tablet'):
if type(val) is not bool:raise ValueError('设备显隐必须是布尔值')
normalized['style'][key]=val
if block['type']=='layout':
if depth>=2:raise ValueError('多列容器最多嵌套两层')
cols=block.get('columns')
if not isinstance(cols,list) or not 1<=len(cols)<=4:raise ValueError('一行需要1-4列')
tablet_cols=block.get('tablet_columns',2)
mobile_cols=block.get('mobile_columns',1)
if type(tablet_cols) is not int or not 1<=tablet_cols<=3:raise ValueError('平板最多支持 1-3 列')
if type(mobile_cols) is not int or not 1<=mobile_cols<=2:raise ValueError('手机最多支持 1-2 列')
sanitized=[]
for col in cols:
if not isinstance(col,dict):raise ValueError('列配置无效')
width=col.get('width',1)
if type(width) not in (int,float) or not .5<=width<=4:raise ValueError('列宽必须在0.5-4之间')
responsive={}
for device,limit in [('tablet',tablet_cols),('mobile',mobile_cols)]:
order=col.get(device+'_order',len(sanitized)+1)
span=col.get(device+'_span',1)
hidden=col.get('hide_'+device,False)
if type(order) is not int or not 0<=order<=20:raise ValueError('列顺序必须在0-20之间')
if type(span) is not int or not 1<=span<=limit:raise ValueError('设备列跨度超过当前列数')
if type(hidden) is not bool:raise ValueError('列显隐必须是布尔值')
responsive.update({device+'_order':order,device+'_span':span,'hide_'+device:hidden})
sanitized.append({'width':float(width),'blocks':normalize(col.get('blocks',[]),depth+1),**responsive})
normalized['columns']=sanitized
normalized['gap']=max(0,min(64,int(block.get('gap',24))))
normalized['tablet_columns']=tablet_cols
normalized['mobile_columns']=mobile_cols
if block['type']=='hero' and 'slides' in block: normalized['slides']=[dict(x) for x in block['slides']]
if block['type'] in ('carousel','tabs','pricing'):
items=block.get('items',[])
if not isinstance(items,list) or len(items)>16:raise ValueError('轮播、标签和方案最多16条')
if any(not isinstance(x,dict) for x in items):raise ValueError('组件条目必须是对象')
if block['type']=='pricing':
if any(not isinstance(x.get('features',[]),list) or len(x.get('features',[]))>12 for x in items):raise ValueError('每个方案最多12项')
normalized['items']=items
if block['type']=='video' and not isinstance(block.get('url',''),str):raise ValueError('视频链接无效')
if len(json.dumps(normalized,ensure_ascii=False))>100_000:raise ValueError('单一区块数据过大')
clean.append(normalized)
return clean
return normalize(blocks)
def resolve_text(value, site, page):
if not isinstance(value,str):return str(value or '')
def repl(m):
group,name=m.group(1),m.group(2)
if group=='site' and name in ('name','tagline','logo'): return str(site.get(name) or '')
if group=='page' and name in ('title','summary','cover','body'):return str(page.get(name) or '')
return ''
return re.sub(r'\{\{\s*(site|page)\.([a-z_]+)\s*\}\}', repl,value)
def safe_media(raw):
value=(raw or '').strip()
p=urlparse(value)
if p.scheme and p.scheme not in ('http','https'):return ''
if value.startswith('//') or any(c in value for c in ('"','<','>','\n','\r')):return ''
# Uploaded media and static assets must be root-relative: relative URLs
# resolve against nested /products/.../ routes and otherwise return 404.
if value.startswith(('media/','assets/')):value='/'+value
return escape(value,quote=True)
def safe_href(raw):
value=(raw or '').strip()
p=urlparse(value)
if p.scheme and p.scheme not in ('http','https','mailto','tel'):return '/'
if value.startswith('//') or any(c in value for c in ('"','<','>','\n','\r')):return '/'
if not p.scheme:
try:return escape(route_path(value),quote=True)
except ValueError:return '/'
return escape(value,quote=True)
def safe_video_url(value):
"""Only allow HTTPS embed origins with a narrowly whitelisted host."""
from urllib.parse import urlsplit,quote
p=urlsplit(str(value or '').strip())
try:
if p.scheme!='https' or p.username or p.password or p.port not in (None,443):return ''
except ValueError:return ''
host=(p.hostname or '').lower()
path=p.path.strip('/')
import re as _re
if host in ('www.youtube.com','youtube.com','www.youtube-nocookie.com','youtube-nocookie.com'):
m=_re.fullmatch(r'(?:embed/|watch/)?([A-Za-z0-9_-]{11})',path)
if m:return 'https://www.youtube-nocookie.com/embed/'+m.group(1)
from urllib.parse import parse_qs
if path=='watch':
val=parse_qs(p.query).get('v',[''])[0]
if _re.fullmatch('[A-Za-z0-9_-]{11}',val):return 'https://www.youtube-nocookie.com/embed/'+val
if host in ('player.vimeo.com','vimeo.com','www.vimeo.com'):
m=_re.fullmatch(r'(?:video/)?([0-9]{1,14})',path)
if m:return 'https://player.vimeo.com/video/'+m.group(1)
return ''
def safe_map_url(value):
from urllib.parse import urlsplit
p=urlsplit(str(value or '').strip())
try:
if p.scheme!='https' or p.hostname not in ('www.google.com','maps.google.com','www.openstreetmap.org','openstreetmap.org') or p.username or p.password or p.port not in (None,443):return ''
except ValueError:return ''
return escape(p.geturl(),quote=True)
def render_blocks(blocks, site, page, entries):
"""One trusted rendering implementation for public HTML and editor previews."""
parts=[]
for block in validate_blocks(blocks):
if block.get('visible') is False:continue
typ=block['type']; resolve=lambda key,default='':resolve_text(block.get(key,default),site,page)
title=escape(resolve('title')); subtitle=escape(resolve('subtitle'));desc=escape(resolve('description'))
raw_style=block.get('style') or {}; s=[]
for key,var in [('background','--sf-bg'),('text_color','--sf-fg')]:
val=str(raw_style.get(key,'')).strip()
if re.fullmatch(r'#[a-fA-F0-9]{6}',val):s.append(f'{var}:{val}')
if 'padding' in raw_style:
try:pad=max(0,min(120,int(raw_style['padding'])));s.append(f'--sf-pad:{pad}px')
except (ValueError,TypeError):pass
style=' style="'+escape(';'.join(s),quote=True)+'"' if s else ''
start=f'<section class="sf-section sf-{typ}" data-block="{escape(block["id"])}"{style}><div class="sf-container">'
end='</div></section>'
if typ=='hero':
slides=block.get('slides') or []
if slides:
panels=[]
for idx,slide in enumerate(slides[:6]):
# Text binding is resolved per slide; outputs are escaped at HTML boundaries.
st=escape(resolve_text(slide.get('title',''),site,page))
ss=escape(resolve_text(slide.get('subtitle',''),site,page))
sd=escape(resolve_text(slide.get('description',''),site,page))
si=safe_media(resolve_text(slide.get('image',''),site,page))
button=escape(resolve_text(slide.get('button',''),site,page) or '查看详情')
link=safe_href(resolve_text(slide.get('href','/product/'),site,page))
art=(f'<div class="sf-hero-img"><img src="{si}" alt="{st}" loading="{'eager' if idx==0 and page.get('route')=='/' else 'lazy'}"{' fetchpriority="high"' if idx==0 and page.get('route')=='/' else ''}></div>' if si else '<div class="sf-hero-img sf-hero-abstract" aria-hidden="true"><span>✧</span></div>')
heading='h1' if idx==0 else 'h2' # one primary page heading across all slides
panels.append(f'<div class="sf-hero-panel" role="group" aria-label="横幅 {idx+1} / {len(slides)}"><div class="sf-hero-grid sf-hero-layout-{block.get("layout_variant","split")} sf-fit-{block.get("image_fit","cover")}"><div class="sf-hero-copy"><span class="sf-kicker">{ss}</span><{heading}>{st}</{heading}><p>{sd}</p><a class="sf-button" href="{link}">{button}</a></div>{art}</div></div>')
controls=('<div class="sf-hero-controls"><button type="button" data-sf-slide-step="-1" aria-label="上一张横幅">‹</button><span class="sf-hero-count" aria-live="polite">1 / '+str(len(slides))+'</span><button type="button" data-sf-slide-step="1" aria-label="下一张横幅">›</button></div>' if len(slides)>1 else '')
parts.append(start+f'<div class="sf-hero-slider" data-sf-hero-slider><div class="sf-hero-track" tabindex="0" role="region" aria-label="横幅内容">{"".join(panels)}</div>{controls}</div>'+end)
continue
image=safe_media(resolve('image'))
art=f'<div class="sf-hero-img"><img src="{image}" alt="{title}" loading="{'eager' if page.get('route')=='/' else 'lazy'}"{' fetchpriority="high"' if page.get('route')=='/' else ''}></div>' if image else '<div class="sf-hero-img sf-hero-abstract"><span>✧</span></div>'
variant=block.get('layout_variant','split')
fit=block.get('image_fit','cover')
visual_cls=(f'sf-hero-layout-{variant} sf-fit-{fit}' if 'layout_variant' in block or 'image_fit' in block else '')
legacy_action='<span>↗</span>' if not visual_cls else ''
parts.append(start+f'<div class="sf-hero-grid{(" "+visual_cls) if visual_cls else ""}"><div class="sf-hero-copy"><span class="sf-kicker">'+subtitle+'</span><h1>'+title+'</h1><p>'+desc+'</p><a class="sf-button" href="'+safe_href(resolve('href','/product/'))+'">'+escape(resolve('button','探索更多'))+legacy_action+'</a></div>'+art+'</div>'+end)
elif typ=='richtext':
body=sanitize(resolve('body'))
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-prose">{body}</div>'+end)
elif typ=='features':
items=block.get('items') if isinstance(block.get('items'),list) else []
cards=''.join(f'<article class="sf-feature"><span>{i+1:02}</span><h3>{escape(str(item.get("title","")))}</h3><p>{escape(str(item.get("text","")))}</p></article>' for i,item in enumerate(items[:12]) if isinstance(item,dict))
visible_items=sum(1 for item in items[:12] if isinstance(item,dict))
feature_cols=visible_items if 1<=visible_items<=4 else 3
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-features" style="--sf-feature-cols:{feature_cols}">{cards}</div>'+end)
elif typ=='stats':
items=block.get('items') if isinstance(block.get('items'),list) else []
cards=''.join(f'<div><strong>{escape(str(item.get("value","")))}</strong><span>{escape(str(item.get("label","")))}</span></div>' for item in items[:12] if isinstance(item,dict))
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-stats">{cards}</div>'+end)
elif typ=='collection':
source=str(block.get('source') or 'product');source=source if source in ('product','post','page','service','case','brand','vehicle','other','all') else 'product'
bound=[r for r in entries if r.get('route')!=page.get('route') and (source=='all' or r.get('type')==source)]
if page.get('type')=='brand' and source in ('product','vehicle'):
def brand_of(record):
try:return str((json.loads(record.get('meta_json') or '{}').get('fields') or {}).get('brand','')).casefold().strip()
except (ValueError,TypeError,AttributeError):return ''
bound=[r for r in bound if brand_of(r)==str(page.get('title','')).casefold().strip()]
count=max(1,min(48,int(block.get('limit') or 6)));cols=max(2,min(4,int(block.get('columns') or 3)))
cards=''.join(f'<a class="sf-item" data-category="{escape(str((json.loads(r.get("meta_json") or "{}").get("fields") or {}).get("category", "")),quote=True)}" data-brand="{escape(str((json.loads(r.get("meta_json") or "{}").get("fields") or {}).get("brand", "")),quote=True)}" href="{safe_href(r.get("route") or "/")}"><div class="sf-item-img">'+(f'<img src="{safe_media(r.get("cover"))}" alt="{escape(r.get("title",""),quote=True)}" loading="lazy">' if safe_media(r.get('cover')) else '<div class="sf-placeholder">'+escape(r.get('title','')[:2])+'</div>')+f'</div><div class="sf-item-copy"><small>{escape(r.get("type",""))}</small><h3>{escape(r.get("title",""))}</h3><p>{escape((r.get("summary") or "")[:140])}</p><span>查看详情 →</span></div></a>' for r in bound[:count])
# Homepage highlights are not catalogues: avoid injecting search/filters before featured cards.
# Other listing pages may opt into filters for sufficiently large result sets.
filterable=(page.get('route')!='/' and (page.get('type') in ('category','brand') or count>=9))
card_style=block.get('card_style','standard')
image_fit=block.get('image_fit','contain')
card_classes=(' sf-card-style-'+card_style if 'card_style' in block else '')+(' sf-fit-'+image_fit if 'image_fit' in block else '')
parts.append(start+f'<div class="sf-section-head"><div><span class="sf-kicker">{subtitle}</span><h2>{title}</h2></div><a href="{safe_href(resolve("href","/product/"))}">{escape(resolve("button","查看全部"))}</a></div><div class="sf-collection{card_classes}" data-sf-filterable="{str(filterable).lower()}" style="--sf-cols:{cols}">{cards if cards else "<p class=\"sf-muted\">当前暂无相关内容,请导入或添加数据。</p>"}</div>'+end)
elif typ=='image_text':
im=safe_media(resolve('image')); img=f'<img src="{im}" alt="{title}" loading="lazy">' if im else '<div class="sf-placeholder sf-large">✧</div>'
side=' sf-reverse' if block.get('image_side')=='left' else ''
parts.append(start+f'<div class="sf-image-text{side}"><div><span class="sf-kicker">{subtitle}</span><h2>{title}</h2><p>{desc}</p><a class="sf-link" href="{safe_href(resolve("href","/about/"))}">{escape(resolve("button","了解更多"))} →</a></div><div class="sf-image-side">{img}</div></div>'+end)
elif typ=='attributes':
raw=page.get('meta_json') or '{}'
try: meta=json.loads(raw) if isinstance(raw,str) else raw
except (ValueError,TypeError):meta={}
props=(meta.get('fields') if isinstance(meta.get('fields'),dict) else meta) or {}
specs=(site.get('schemas') or {}).get(page.get('type'),[])
candidates=[(field['name'],field.get('label') or field['name']) for field in specs]
if not candidates:
candidates=[(k,k.replace('_',' ').title()) for k in props.keys() if k not in ('blocks','page_template','wp_metadata','wp_post_id','wp_type','fields') and isinstance(props[k],(str,int,float,bool))]
source_mode=block.get('source_mode','page')
pairs=''
if source_mode in ('page','merge'):
pairs=''.join(f'<div class="sf-attribute"><span>{escape(str(label))}</span><strong>{escape(str(props[key]))}</strong></div>' for key,label in candidates if props.get(key) not in (None,''))
if source_mode in ('custom','merge'):
manual=''.join(f'<div class="sf-attribute"><span>{escape(resolve_text(item["label"],site,page))}</span><strong>{escape(resolve_text(item["value"],site,page))}</strong></div>' for item in block.get('items',[])[:30] if item.get('label') and item.get('value'))
pairs+=manual
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-attributes">{pairs if pairs else "<p class=\"sf-muted\">参数尚未填写</p>"}</div>'+end)
elif typ=='gallery':
pics=block.get('images') if isinstance(block.get('images'),list) else []
imgs=''.join(f'<img src="{safe_media(str(img.get("url") or ""))}" alt="{escape(str(img.get("alt") or ""),quote=True)}" loading="lazy">' for img in pics[:24] if isinstance(img,dict) and safe_media(str(img.get('url') or '')))
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-gallery">{imgs or "<p>可在编辑器中添加图片</p>"}</div>'+end)
elif typ=='faq':
items=block.get('items') if isinstance(block.get('items'),list) else []
body=''.join(f'<details><summary>{escape(str(item.get("question","")))}</summary><p>{escape(str(item.get("answer","")))}</p></details>' for item in items[:20] if isinstance(item,dict))
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-faq">{body}</div>'+end)
elif typ=='cta':
parts.append(start+f'<div class="sf-cta-wrap"><div><span class="sf-kicker">LET’S CONNECT</span><h2>{title}</h2><p>{subtitle}</p></div><a class="sf-button" href="{safe_href(resolve("href","/contact/"))}">{escape(resolve("button","联系我们"))} ↗</a></div>'+end)
elif typ=='carousel':
items=block.get('items',[])
cards=[]
for item in items[:16]:
url=safe_media(str(item.get('image') or ''))
if not url:continue
alt=escape(str(item.get('alt') or '展示图片'),quote=True)
caption=escape(str(item.get('caption') or ''))
cards.append(f'<figure class="sf-slide"><img src="{url}" alt="{alt}" loading="lazy"><figcaption>{caption}</figcaption></figure>')
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-slides" role="region" aria-label="{title}" tabindex="0">'+''.join(cards)+'</div>'+end)
elif typ=='video':
url=safe_video_url(resolve('url'))
embed=f'<iframe title="{title}" src="{url}" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="fullscreen; picture-in-picture" allowfullscreen></iframe>' if url else '<p class="sf-muted">请填写允许的 YouTube/Vimeo HTTPS 视频链接</p>'
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-video">{embed}</div><p>{desc}</p>'+end)
elif typ=='tabs':
items=block.get('items',[])
panels=''.join('<details class="sf-tab"'+(' open' if i==0 else '')+'><summary>'+escape(str(it.get('label','标签')))+'</summary><p>'+escape(str(it.get('text','')))+'</p></details>' for i,it in enumerate(items[:16]))
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-tab-list">{panels}</div>'+end)
elif typ=='pricing':
cards=''
for item in block.get('items',[])[:16]:
features=''.join('<li>'+escape(str(feat))+'</li>' for feat in item.get('features',[])[:12])
cards+='<article class="sf-price"><h3>'+escape(str(item.get('name','方案')))+'</h3><strong>'+escape(str(item.get('price','联系报价')))+'</strong><ul>'+features+'</ul><a class="sf-button" href="'+safe_href(str(item.get('href','/contact/')))+'">了解详情</a></article>'
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-prices">{cards}</div>'+end)
elif typ=='map':
link=safe_map_url(resolve('url'))
action=f'<a class="sf-button" href="{link}" rel="noopener noreferrer" target="_blank">打开地图 ↗</a>' if link else ''
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><address class="sf-map-address">{escape(resolve("address"))}</address>{action}'+end)
elif typ=='form_link':
parts.append(start+'<div class="sf-cta-wrap"><div><h2>'+title+'</h2><p>'+desc+'</p></div><a class="sf-button" href="'+safe_href(resolve('href','/contact/'))+'">'+escape(resolve('button','提交询盘'))+' →</a></div>'+end)
elif typ=='layout':
columns=block['columns'];gap=block.get('gap',24)
weights=' '.join(f'{col["width"]}fr' for col in columns)
html_cols=[]
for ci,col in enumerate(columns):
html_cols.append(f'<div class="sf-layout-column" data-col="{ci+1}">'+render_blocks(col['blocks'],site,page,entries)+'</div>')
parts.append(start+f'<div class="sf-layout-grid" data-layout="{escape(block["id"],quote=True)}" style="--sf-layout-cols:{escape(weights,quote=True)};--sf-layout-gap:{gap}px">'+''.join(html_cols)+'</div>'+end)
elif typ=='spacer':
try:height=max(8,min(160,int(block.get('height',48))))
except (ValueError,TypeError):height=48
parts.append(f'<div class="sf-spacer" style="height:{height}px"></div>')
return ''.join(parts)
def theme_blocks(theme,kind,version=None):
from pathlib import Path
from . import theme_store
path=theme_store.resolve(theme,version)/'page-presets.json'
if not path.is_file():return sample_blocks(kind)
data=json.loads(path.read_text(encoding='utf-8'))
# IDs must be unique per created page.
return [new_block(b['type'],{k:v for k,v in b.items() if k not in ('type','id')}) for b in data.get(kind, data['standard'])]