Files
cms-SiteForge/app/blueprints.py
T

231 lines
19 KiB
Python
Raw Normal View History

"""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'], 'defaults':{'columns':[{'width':1,'blocks':[]},{'width':1,'blocks':[]}],'gap':24}},
'spacer':{'name':'上下留白','icon':'↕','fields':['height'],'defaults':{'height':48}},
}
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']={k:v for k,v in style.items() if k in ('background','text_color','padding','tablet_padding','mobile_padding','hide_mobile','hide_tablet') and isinstance(v,(str,int,float,bool))}
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列')
sanitized=[]
for col in cols:
if not isinstance(col,dict):raise ValueError('列配置无效')
width=col.get('width',1)
if not isinstance(width,(int,float)) or not .5<=width<=4:raise ValueError('列宽必须在0.5-4之间')
sanitized.append({'width':float(width),'blocks':normalize(col.get('blocks',[]),depth+1)})
normalized['columns']=sanitized
normalized['gap']=max(0,min(64,int(block.get('gap',24))))
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 ''
return escape(value.lstrip('/') if value.startswith('/') else 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).lstrip('/') or './',quote=True)
except ValueError:return '/'
return escape(value,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=='layout':
columns=block['columns'];gap=block.get('gap',24)
weights=' '.join(f'{col["width"]}fr' for col in columns)
html_cols=[]
for col in columns:
html_cols.append('<div class="sf-layout-column">'+render_blocks(col['blocks'],site,page,entries)+'</div>')
parts.append(start+f'<div class="sf-layout-grid" 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'])]