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>
This commit is contained in:
浅念
2026-10-26 18:03:07 +08:00
parent 391d22f1a8
commit df2a183dbf
38 changed files with 1652 additions and 521 deletions
+68 -8
View File
@@ -9,16 +9,16 @@ 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'}},
'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'],'defaults':{'title':'精选产品','subtitle':'为客户提供适合的解决方案','source':'product','limit':6,'columns':3,'button':'查看全部','href':'/product/'}},
'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'],'defaults':{'title':'产品参数'}},
'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':'图片说明'}]}},
@@ -32,7 +32,7 @@ BLOCKS = {
# 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
'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'],
@@ -104,6 +104,35 @@ def validate_blocks(blocks):
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
@@ -153,6 +182,7 @@ def validate_blocks(blocks):
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条')
@@ -245,9 +275,30 @@ def render_blocks(blocks, site, page, entries):
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="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)
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)
@@ -274,7 +325,10 @@ def render_blocks(blocks, site, page, entries):
# 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))
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" data-sf-filterable="{str(filterable).lower()}" style="--sf-cols:{cols}">{cards if cards else "<p class=\"sf-muted\">当前暂无相关内容,请导入或添加数据。</p>"}</div>'+end)
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 ''
@@ -288,7 +342,13 @@ def render_blocks(blocks, site, page, entries):
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,''))
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 []