{escape(str(item.get("title","")))}
{escape(str(item.get("text","")))}
"""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':'
在这里输入图文内容。
'}}, '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':'请填写企业联系方式与地址。
'}),('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'{escape(str(item.get("text","")))}
{escape((r.get("summary") or "")[:140])}
查看详情 →当前暂无相关内容,请导入或添加数据。
"}参数尚未填写
"}可在编辑器中添加图片
"}{escape(str(item.get("answer","")))}
{subtitle}
请填写允许的 YouTube/Vimeo HTTPS 视频链接
' parts.append(start+f'{desc}
'+end) elif typ=='tabs': items=block.get('items',[]) panels=''.join(''+escape(str(it.get('text','')))+'
'+desc+'