8cea7dee20
- SiteForge Enterprise V8.10.0-dev.1 upstream source (210 tests) - Fix: v8_workflows._mapped_input now reads both 类型/分类 columns so WXR imports keep post/product types instead of collapsing to page - Docs: trade-pro theme port spec (R3) and implementation plan Co-Authored-By: Claude Code <noreply@anthropic.com>
345 lines
28 KiB
Python
345 lines
28 KiB
Python
"""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'], 'defaults':{'title':'让品牌从这里出发','subtitle':'GLOBAL EXPERIENCE','description':'在这里呈现企业最重要的价值。','button':'探索产品','href':'/product/','align':'left'}},
|
||
'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'],'defaults':{'title':'精选产品','subtitle':'为客户提供适合的解决方案','source':'product','limit':6,'columns':3,'button':'查看全部','href':'/product/'}},
|
||
'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'],'defaults':{'title':'产品参数'}},
|
||
'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') 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']]
|
||
allowed=set(spec['fields'])|{'id','type','visible','style'}
|
||
normalized={key:val for key,val in block.items() if key in allowed}
|
||
normalized['id']=ident
|
||
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'] 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':
|
||
image=safe_media(resolve('image'))
|
||
art=f'<div class="sf-hero-img"><img src="{image}" alt="{title}" loading="lazy"></div>' if image else '<div class="sf-hero-img sf-hero-abstract"><span>✧</span></div>'
|
||
parts.append(start+'<div class="sf-hero-grid"><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','探索更多'))+' <span>↗</span></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))
|
||
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-features">{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])
|
||
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" 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))]
|
||
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,''))
|
||
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'])]
|