175 lines
9.9 KiB
Python
175 lines
9.9 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=('automotive','industrial','corporate','minimal')
|
||
|
|
ROOT=Path(__file__).resolve().parent.parent
|
||
|
|
LOADER=Environment(loader=FileSystemLoader(str(ROOT/'themes')),autoescape=select_autoescape(['html','xml']))
|
||
|
|
|
||
|
|
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 make_context(site,page,records,mode='detail',listing_items=None):
|
||
|
|
pg=dict(page or {})
|
||
|
|
kind=infer_kind(pg,mode)
|
||
|
|
groups=all_groups(records)
|
||
|
|
listing=select_listing(pg,kind,groups,records,listing_items)
|
||
|
|
if listing_items and mode=='listing' and not (pg.get('route') or '').startswith('/categories/'):
|
||
|
|
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':[{'name':c,'url':'/categories/'+slug(c)+'/'} for c in categories(records)],
|
||
|
|
'brands':brands(records)}
|
||
|
|
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 ''))
|
||
|
|
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'}.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, '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':settings.get('email') or site.get('email') or '',
|
||
|
|
'footer_text':settings.get('footer_text') or site.get('tagline') or '',
|
||
|
|
'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=''):
|
||
|
|
if theme not in THEMES:raise ValueError('不是内置专业主题')
|
||
|
|
ctx=make_context(site,page,records,mode,listing_items)
|
||
|
|
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 LOADER.get_template(theme+'/pro.html').render(**ctx)
|