Files
cms-SiteForge/app/theme_pro.py
T
2026-10-22 18:51:27 +08:00

208 lines
12 KiB
Python

"""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
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
_ENVS={}
def env_for(theme):
"""Per-theme Jinja env: theme dir first, shared themes/ root as fallback.
Lets a theme override shared macros (_professional_components.html) while
inheriting everything else. Cached: one env per theme across large builds.
Only built-in theme names are accepted; callers are expected to enforce the
THEMES whitelist too (render does), this is defense in depth.
"""
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 # degraded envs are not cached
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
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)
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 ('brand','category','model','description','image','cover','url')]
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 ''
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,
'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')),
'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)