"""Full, version-aware page-family theme renderer.
All content is escaped by Jinja's HTML autoescaping. Templates packaged with the
application are trusted; uploaded declarative themes cannot supply executable Jinja.
"""
from __future__ import annotations
import hashlib
import datetime
import json
import re
from html import escape, unescape
from pathlib import Path
from urllib.parse import quote
from jinja2 import Environment, FileSystemLoader, select_autoescape
from markupsafe import Markup
from .core import sanitize, route_path, THEMES
ROOT=Path(__file__).resolve().parent.parent
LOADER=Environment(loader=FileSystemLoader(str(ROOT/'themes')),autoescape=select_autoescape(['html','xml']))
_ENVS={}
def env_for(theme):
"""Per-theme Jinja env: theme dir first, shared themes/ root as fallback."""
if theme not in THEMES:raise ValueError('未知主题')
env=_ENVS.get(theme)
if env is None:
theme_dir=ROOT/'themes'/theme
canonical=theme_dir.is_dir()
search=[str(theme_dir),str(ROOT/'themes')] if canonical else [str(ROOT/'themes')]
env=Environment(loader=FileSystemLoader(search),autoescape=select_autoescape(['html','xml']))
if canonical:_ENVS[theme]=env
return env
TITLES={'product':'产品中心','vehicle':'车型中心','post':'新闻资讯','case':'项目案例','brand':'品牌中心','service':'服务与解决方案'}
def meta(item):
try:
raw=item.get('meta_json') or '{}'
val=json.loads(raw) if isinstance(raw,str) else raw
return val if isinstance(val,dict) else {}
except (TypeError,ValueError):return {}
def fields(item):
v=meta(item).get('fields') or {}
return v if isinstance(v,dict) else {}
def text(item,key,limit=500):
return str(item.get(key) or '')[:limit]
def slug(value):
# Conservative URL generation for newly derived pages; never rewrite imported URLs.
raw=str(value).strip().casefold()
ascii_slug=re.sub(r'[^a-z0-9-]+','-',raw).strip('-')[:54]
return ascii_slug or ('term-'+hashlib.sha256(raw.encode()).hexdigest()[:12])
def all_groups(records):
return {typ:[e for e in records if e.get('type')==typ] for typ in TITLES}
def categories(records):
return sorted({str(fields(e).get('category') or '').strip() for e in records if e.get('type') in ('product','vehicle') and str(fields(e).get('category') or '').strip()},key=str.casefold)
def brands(records):
found={}
for entry in records:
if entry.get('type')=='brand':found[entry['title'].casefold()]={'name':entry['title'],'url':entry['route']}
for entry in records:
if entry.get('type') not in ('product','vehicle'):continue
name=str(fields(entry).get('brand') or '').strip()
if name and name.casefold() not in found:found[name.casefold()]={'name':name,'url':'/brands/'+slug(name)+'/'}
return sorted(found.values(),key=lambda e:e['name'].casefold())
def infer_kind(page,mode='detail'):
path=page.get('route') or '/'
typ=page.get('type') or 'page'
explicit=str(meta(page).get('page_template') or '').strip().lower()
if mode=='home' or path=='/':return 'home'
if mode=='notfound':return 'notfound'
if path=='/search/':return 'search'
if path.startswith('/categories/'):return 'category'
if mode=='listing' or explicit=='listing' or path in ('/products/','/brands/','/news/','/cases/','/product/','/post/','/brand/','/case/','/service/','/vehicle/'):
return 'listing'
if typ in ('product','vehicle'):return 'product'
if typ=='post':return 'article'
if typ=='brand':return 'brand'
if typ=='case':return 'case'
if typ=='service':return 'service'
if explicit in ('home','about','contact','listing','product','article','brand','standard','notfound','category','case','service','faq'):return explicit
if path.startswith('/about'):return 'about'
if path.startswith('/contact'):return 'contact'
if path.startswith('/faq'):return 'faq'
if path.startswith('/services'):return 'service'
return 'standard'
def safe_href(route):
if not isinstance(route,str):return '/'
if route.startswith(('mailto:','tel:','https://','http://')):return route
try:return route_path(route)
except ValueError:return '/'
def safe_image(value):
if not value or not isinstance(value,str):return ''
value=value.strip()
if value.startswith('//') or any(c in value for c in ('<','>','"','\n','\r')):return ''
if value.startswith(('https://','http://')):return value
if value.startswith('/') or not value.startswith(('data:','javascript:')):return '/'+value.lstrip('/')
return ''
def media_gallery(page):
"""Accept JSON gallery arrays or explicit semicolon/newline separated URLs."""
attrs=fields(page)
raw=attrs.get('gallery') or attrs.get('images') or meta(page).get('gallery') or []
if isinstance(raw,str):
try: raw=json.loads(raw)
except (ValueError,TypeError):raw=re.split(r'[;\n]+',raw)
if not isinstance(raw,list):raw=[]
results=[]
for item in [page.get('cover')]+raw:
uri=item.get('url','') if isinstance(item,dict) else item
safe=safe_image(uri)
if safe and safe not in results:results.append(safe)
if len(results)>=20:break
return results
# ── 产品详情自定义区块(CMS wppop_sections 迁移:Packing & Delivery / Our
# Exhibition / Our Certifications / Our Company / FAQ——标题来自每产品数据,
# 不固定)。数据侧两个来源(Task 15):
# 1) fields.sections:同步脚本结构化写入的 [{title, content}] JSON;
# 2) 旧同步产物把区块追加进 body 尾部
段——按标题识别拆出为独立区块,
# 避免正文面板与区块面板重复渲染。正文自有的其他 H2 标题不受影响。──
KNOWN_SECTION_TITLES=frozenset((
'packing & delivery','our exhibition','our certifications','our company','faq','features',
))
_H2_RE=re.compile(r']*>([\s\S]*?)
',re.I)
def plain_text(fragment):
"""HTML 片段 → 单行纯文本(标签剥离 + 实体解码),标题比对用。"""
return ' '.join(unescape(re.sub(r'<[^>]+>',' ',fragment or '')).split()).strip()
def parse_pairs(value,keys):
"""fields 里的 JSON 列表(sections/features)→ [{'title','content'|'desc'}]。
容忍 JSON 字符串与已解析列表两种形态;缺标题或缺内容的条目丢弃,上限 12 条。
"""
if isinstance(value,str):
try:value=json.loads(value)
except (TypeError,ValueError):return []
if not isinstance(value,list):return []
out=[]
for item in value:
if not isinstance(item,dict):continue
title=str(item.get('title') or '').strip()
payload=str(item.get(keys[1]) or '').strip()
if title and payload:
out.append({'title':title,keys[1]:payload})
return out[:12]
def split_body_sections(body,wanted):
"""按 H2 标题把 body 拆为 (overview_html, sections)。
wanted 为小写标题集合:命中的 H2 与其后到下一个 H2 前的内容拆出为区块;
未命中的 H2 与其余内容原样留在 overview(正文自有标题不误拆)。连续命中
依次成段;空内容区块不拆(保留原状)。
"""
matches=list(_H2_RE.finditer(body or ''))
overview_parts,sections,last=[],[],0
for i,m in enumerate(matches):
title=plain_text(m.group(1))
if title.casefold() not in wanted:continue
end=matches[i+1].start() if i+1;同名标题追加序号保唯一(sec-faq-1)。"""
used={}
out=[]
for sec in sections:
base='sec-'+(slug(sec['title']) or 'section')
n=used.get(base,0)
used[base]=n+1
out.append(base if n==0 else f'{base}-{n}')
return out
def product_detail_sections(page,attrs):
"""产品详情 Tab 区块数据:[(id, title, content HTML)] + features 列表。
fields.sections(结构化)优先,body H2 识别兜底;两者都有的同名区块只保留
结构化版本。Features:fields.features 有数据时 body 里的同名 H2 段拆出丢弃
(由结构化数据渲染,防重复)。
"""
structured=parse_pairs(attrs.get('sections'),('title','content'))
features=parse_pairs(attrs.get('features'),('title','desc'))
covered={s['title'].casefold() for s in structured}
if features:covered.add('features')
wanted=set(KNOWN_SECTION_TITLES)|covered
remainder,body_sections=split_body_sections(str(page.get('body') or ''),wanted)
merged=list(structured)
for sec in body_sections:
if sec['title'].casefold() not in covered:
merged.append(sec)
covered.add(sec['title'].casefold())
merged=merged[:12]
return remainder,[
{'id':sid,'title':sec['title'],'content':Markup(sanitize(sec['content']))}
for sid,sec in zip(section_ids(merged),merged)],features
def select_listing(page,kind,groups,all_records,requested):
if kind=='category':
category=text(page,'category') or text(page,'title')
return [e for e in groups['product']+groups['vehicle'] if str(fields(e).get('category') or '').casefold()==category.casefold()]
path=(page.get('route') or '').lower()
title=text(page,'title').lower()
template=meta(page)
source=next((b.get('source') for b in template.get('blocks',[]) if isinstance(b,dict) and b.get('type')=='collection' and b.get('source')),'')
category= 'post' if ('news' in path or '新闻' in title) else ('brand' if ('brand' in path or '品牌' in title) else ('case' if ('case' in path or '案例' in title) else ('service' if ('service' in path or '服务' in title) else 'product')))
category=source if source in groups else category
if requested and kind=='listing' and not source and path.startswith(('/product/','/post/','/brand/','/case/','/service/')):
category={'/product/':'product','/post/':'post','/brand/':'brand','/case/':'case','/service/':'service/'} .get(path,category)
if category=='product':return groups['product']+groups['vehicle']
return groups.get(category,[])
def prepare(records):
# Grouping a 5,000-page catalogue for every detail page is quadratic.
# The per-build object is scoped to one site; no cross-request global cache.
grouped=all_groups(records)
return {'groups':grouped,
'brands':brands(records),
'categories':[{'name':x,'url':'/categories/'+slug(x)+'/'} for x in categories(records)]}
def make_context(site,page,records,mode='detail',listing_items=None,prepared=None):
pg=dict(page or {})
kind=infer_kind(pg,mode)
prepared=prepared or prepare(records)
groups=prepared['groups']
# Detail templates never render the complete listing; avoid copying thousands
# of records into an unused array for every detail page.
listing=select_listing(pg,kind,groups,records,listing_items) if kind in ('listing','category','search','home') else []
if listing_items is not None and mode=='listing':
listing=list(listing_items)
detail_fields=fields(pg)
# 结构化 JSON/列表字段(同步脚本写入的 sections/features/specs/gallery 等
# 附件数据)不进 Specifications 表——表中只列真正的标量规格键。
STRUCTURED_FIELD_KEYS=('brand','category','model','description','image','cover','url',
'gallery','sections','features','specs','categories')
spec=[{'label':str(k).replace('_',' ').capitalize(),'value':str(v)} for k,v in detail_fields.items() if isinstance(v,(str,int,float)) and v not in ('',None) and k not in STRUCTURED_FIELD_KEYS]
ownbrand=str(detail_fields.get('brand') or pg.get('title') or '').strip()
related=[x for x in groups['product']+groups['vehicle'] if str(fields(x).get('brand') or '').casefold()==ownbrand.casefold()] if kind=='brand' else []
settings=json.loads(site.get('settings_json') or '{}') if isinstance(site.get('settings_json'),str) else site.get('settings_json',{}) or {}
nav=settings.get('nav') or [
{'title':'首页','route':'/'},{'title':'产品','route':'/products/'},
{'title':'品牌','route':'/brands/'},{'title':'新闻','route':'/news/'},
{'title':'案例','route':'/cases/'},{'title':'关于','route':'/about/'},
{'title':'联系','route':'/contact/'}]
def nav_item(row,depth=0):
output={'title':str(row.get('title',''))[:50],'route':safe_href(row.get('route') or '/')}
children=row.get('children',[]) if depth<2 else []
output['children']=[nav_item(c,depth+1) for c in children[:20] if isinstance(c,dict)] if isinstance(children,list) else []
return output
nav=[nav_item(x) for x in nav if isinstance(x,dict)][:20]
assets={'categories':prepared['categories'],'brands':prepared['brands']}
title=(pg.get('title') or site.get('name') or '')
canonical=(pg.get('canonical') or (site.get('domain','').rstrip('/')+pg.get('route','/') if site.get('domain','').startswith(('http://','https://')) else ''))
from . import site_design_v87
chrome=site_design_v87.published(settings)
if chrome['nav']:nav=chrome['nav']
form_endpoint=settings.get('form_endpoint') or ''
form_endpoint=form_endpoint if isinstance(form_endpoint,str) and form_endpoint.startswith('https://') and not any(x in form_endpoint for x in ('<','>','\"','\n')) else ''
# 产品详情自定义区块(Task 15):fields.sections 结构化优先,body H2 识别
# 兜底;overview 为拆出区块后的剩余正文(产品模板专用,content 保持全量
# 供其他主题继续整体渲染)。
overview_html=pg.get('body') or ''
detail_sections=[];detail_features=[]
if kind=='product':
overview_html,detail_sections,detail_features=product_detail_sections(pg,detail_fields)
return {'year':datetime.datetime.now(datetime.timezone.utc).year,'theme_color':{'automotive':'#0e1c2c','industrial':'#113f59','corporate':'#f5f6fa','minimal':'#f8f7f3','trade-pro':'#0b3d91'}.get(site.get('theme'),'#ffffff'),'gallery':media_gallery(pg),'form_endpoint':form_endpoint,'site':site,'page':pg,'kind':kind,'records':records,'groups':groups,'listing':listing,
'detail_sections':detail_sections,'detail_features':detail_features,'detail_overview':Markup(sanitize(overview_html)),
'showcase':groups['product'][:6]+groups['vehicle'][:max(0,6-len(groups['product'][:6]))],
'features':groups['service'][:3], 'stories':groups['post'][:3], 'cases':groups['case'][:3],
'brands':assets['brands'],'categories':assets['categories'], 'nav':nav,
'chrome':chrome,'chrome_is_custom':bool(settings.get('site_design')),
'mobile_tab_icons':{'home':'⌂','grid':'▦','products':'▤','news':'☷','contact':'✉','phone':'☎','info':'ⓘ','search':'⌕','menu':'☰','star':'☆'},
'footer_nav':nav if chrome['footer']['show_nav'] else [],'specs':spec,
'related':related,'fields':detail_fields,'content':Markup(sanitize(pg.get('body') or '')),
'title':title, 'seo_title':pg.get('seo_title') or title,'seo_description':pg.get('seo_description') or pg.get('summary') or site.get('tagline') or '',
'canonical':canonical,'safe_href':safe_href,'safe_image':safe_image,
'fields_of':fields,'labels':TITLES,'footer_email':chrome['footer']['email'] or settings.get('email') or site.get('email') or '',
'footer_text':chrome['footer']['description'] or settings.get('footer_text') or site.get('tagline') or '',
'pagination':pg.get('_pagination'), 'route':pg.get('route','/'),
'pretty_type':TITLES.get(pg.get('type'),'内容'),
'empty':len(listing)==0,
}
def render(theme,site,page,records,mode='detail',listing_items=None,blocks_html='',prepared=None):
if theme not in THEMES:raise ValueError('不是内置专业主题')
ctx=make_context(site,page,records,mode,listing_items,prepared=prepared)
ctx.update(custom_blocks=Markup(blocks_html or ''),has_custom=bool(blocks_html),noindex=(ctx['kind']=='notfound'),designer_replace=meta(page).get('layout_mode')=='replace')
return env_for(theme).get_template('pro.html').render(**ctx)