"""SiteForge Docker-first website factory API and Web console.""" import io from contextlib import asynccontextmanager import json import re import shutil import uuid from pathlib import Path from typing import Any from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Body, Request from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse, Response, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field from . import console_v819, wp_complex_v820, db, importers, builder, security, blueprints, enterprise, theme_store, factory_api, migration_audit, reusables, jobs, release_api, cms_pipeline, v8_workflows, v8_templates, wp_migration_v81, wp_migration_v82, wp_migration_v83, connectors_v84, connectors_v85, studio_v86, site_design_v87, multilingual from .version import VERSION, BUILD_SHA from .request_trace import TraceMiddleware from .preview_rewrite import rewrite_preview_html from .core import uid, THEMES, TYPES, clean_hex, save_entry, route_path, entry_dict ROOT=Path(__file__).resolve().parent.parent @asynccontextmanager async def lifespan(app): security.configure() db.init() enterprise.setup() factory_api.setup() migration_audit.setup() reusables.setup() release_api.setup() cms_pipeline.setup() v8_workflows.setup() v8_templates.setup() wp_migration_v81.setup() wp_migration_v82.setup() wp_migration_v83.setup() connectors_v84.setup() connectors_v85.setup() studio_v86.setup() site_design_v87.setup() multilingual.setup() yield app=FastAPI(title='SiteForge 网站工厂',description='Docker-first visual website factory',version=VERSION,lifespan=lifespan) app.add_middleware(TraceMiddleware) app.mount('/ui',StaticFiles(directory=str(ROOT/'app'/'static')),name='static') @app.get('/favicon.ico',include_in_schema=False) def favicon(): return FileResponse(ROOT/'app'/'static'/'favicon.ico',media_type='image/x-icon') app.include_router(enterprise.router) app.include_router(factory_api.router) app.include_router(migration_audit.router) app.include_router(reusables.router) app.include_router(release_api.router) app.include_router(cms_pipeline.router) app.include_router(v8_workflows.router) app.include_router(v8_templates.router) app.include_router(wp_migration_v81.router) app.include_router(wp_migration_v82.router) app.include_router(wp_migration_v83.router) app.include_router(wp_complex_v820.router) app.include_router(connectors_v84.router) app.include_router(connectors_v85.router) app.include_router(studio_v86.router) app.include_router(site_design_v87.router) app.include_router(multilingual.router) app.include_router(console_v819.router) @app.middleware('http') async def require_login(request:Request, call_next): path = request.url.path public = path in ('/login', '/api/auth/login', '/api/health', '/api/ready', '/favicon.ico') or path.startswith('/ui/') if not public: session = security.read_session(request.cookies.get(security.COOKIE)) identity = enterprise.account_from_session(session) if session else None if not identity: if path.startswith('/api/') or path in ('/openapi.json',): return JSONResponse({'detail':'请先登录 SiteForge'},status_code=401) return RedirectResponse('/login',status_code=303) request.state.account_id=identity['id'] request.state.is_superadmin=identity['superadmin'] request.state.actor=identity['actor'] if request.method not in ('GET','HEAD','OPTIONS'): if not security.verify_csrf(session,request.headers.get('x-siteforge-csrf','')): return JSONResponse({'detail':'安全验证失败,请刷新页面'},status_code=403) origin=request.headers.get('origin') if origin: from urllib.parse import urlparse parsed=urlparse(origin) forwarded=request.headers.get('x-forwarded-host') or request.headers.get('host') or '' if parsed.netloc.lower()!=forwarded.lower(): return JSONResponse({'detail':'不允许跨站请求'},status_code=403) if not identity['superadmin']: import re segments=re.match(r'^/api/sites/([^/]+)(?:/|$)',path) enterprise_segments=re.match(r'^/api/(?:enterprise|factory|audit|production|migrations|connectors)/sites/([^/]+)(?:/|$)',path) asset_segments=re.match(r'^/(?:editor|preview|local-media)/([^/]+)(?:/|$)',path) site_id=(segments or enterprise_segments or asset_segments) if site_id: site_id=site_id.group(1) role=('viewer' if request.method in ('GET','HEAD') else 'editor') if path.startswith('/api/factory/sites/') and '/release-requests/' in path and path.endswith('/review'): role='reviewer' if any(t in path for t in ('/publish-local','/rollback','/members','/deploy')) or request.method=='DELETE' and segments and segments.group(0).rstrip('/').endswith(site_id): role='owner' if path.endswith('/baseline') and request.method=='GET':role='viewer' try:enterprise.require(request,site_id,role) except HTTPException: return JSONResponse({'detail':'没有访问该网站的权限'},status_code=403) elif path in ('/api/sites','/api/presets') and request.method not in ('GET','HEAD'): return JSONResponse({'detail':'只有系统管理员可创建站点/全局模板'},status_code=403) elif path.startswith('/api/enterprise/team/') or path in ('/docs','/openapi.json'): return JSONResponse({'detail':'没有全局管理权限'},status_code=403) if request.method in ('PATCH','PUT','DELETE') and path.startswith('/api/sites/') and '/entries/' in path: pieces=path.strip('/').split('/') if len(pieces)>=5: enterprise.save_revision(pieces[2],pieces[4],identity['actor'],'before_'+request.method.lower()) response=await call_next(request) if not public: if request.method in ('POST','PUT','PATCH','DELETE'): site_match=__import__('re').match(r'^/api/(?:(?:enterprise|factory|production|migrations|connectors)/)?sites/([^/]+)',path) enterprise.log_event(request.state.actor,path,response.status_code,request.method, site_match.group(1) if site_match else None) if request.method=='GET' and path=='/api/sites' and not request.state.is_superadmin and response.status_code==200: # Apply the same site-scope filter at the top-level list boundary. import json as _json permitted=enterprise.db.rows('SELECT site_id FROM team_memberships WHERE user_id=?',(request.state.account_id,)) ids={r['site_id'] for r in permitted} from starlette.responses import StreamingResponse raw=b'' async for part in response.body_iterator:raw+=part data=_json.loads(raw) response=JSONResponse([x for x in data if x['id'] in ids]) response.headers['X-Content-Type-Options']='nosniff' response.headers['X-Frame-Options']='SAMEORIGIN' if path.startswith('/editor/') else 'DENY' response.headers['Referrer-Policy']='strict-origin-when-cross-origin' response.headers['Cache-Control']='no-store' if path.startswith('/api/auth/') else response.headers.get('Cache-Control','no-cache') return response @app.get('/production-v8',response_class=HTMLResponse) def production_v8(): return (ROOT/'app'/'templates'/'production-v8.html').read_text(encoding='utf-8') @app.get('/theme-gallery',response_class=HTMLResponse) def theme_gallery(): return (ROOT/'app'/'templates'/'theme-gallery.html').read_text(encoding='utf-8') @app.get('/operations',response_class=HTMLResponse) def operations(): return (ROOT/'app'/'templates'/'operations.html').read_text(encoding='utf-8') @app.get('/factory-studio',response_class=HTMLResponse) def factory_studio(): return (ROOT/'app'/'templates'/'factory-v2.html').read_text(encoding='utf-8') @app.get('/login',response_class=HTMLResponse) def login_page(): return (ROOT/'app'/'templates'/'login.html').read_text(encoding='utf-8') class LoginIn(BaseModel): password: str username: str = "admin" @app.post('/api/auth/login') def login(payload:LoginIn, request:Request): remote = request.client.host if request.client else 'unknown' security.check_login_rate(remote) if payload.username=='admin': authenticated=security.check_password(payload.password) token=security.sign_session() if authenticated else None else: user=enterprise.user_by_name(payload.username) authenticated=bool(user and not user['disabled'] and enterprise.check_password(payload.password,user['password_hash'])) token=security.sign_user_session(user['id']) if authenticated else None if not authenticated: security.note_login_failure(remote) raise HTTPException(401, '账号或密码不正确') security.note_login_success(remote) response = JSONResponse({'ok':True}) response.set_cookie(security.COOKIE, token, max_age=security.MAX_AGE, httponly=True, secure=security.COOKIE_SECURE, samesite='strict', path='/') response.headers['Cache-Control'] = 'no-store' return response @app.get('/api/auth/me') def whoami(request:Request): session=security.read_session(request.cookies.get(security.COOKIE)) return {'authenticated':True,'user':request.state.actor,'superadmin':request.state.is_superadmin,'csrf':security.csrf(session),'version':VERSION} @app.post('/api/auth/logout') def logout(): response=JSONResponse({'ok':True}) response.delete_cookie(security.COOKIE,path='/') return response class SiteIn(BaseModel): name:str = Field(min_length=1,max_length=150) domain:str = '' tagline:str = '' theme:str = 'corporate' accent:str = '#2563eb' logo:str = '' mode:str = 'new' origin:str = '' preset_id:str = '' class SitePatch(BaseModel): name:str|None=None domain:str|None=None tagline:str|None=None theme:str|None=None accent:str|None=None logo:str|None=None mode:str|None=None origin:str|None=None class CrawlIn(BaseModel): url:str max_pages:int=20 on_conflict:str='skip' class BuildIn(BaseModel): mirror_images:bool=False class RedirectIn(BaseModel): old_path:str new_path:str status_code:int=301 def get_site(site_id): site=db.one('SELECT * FROM sites WHERE id=?',(site_id,)) if not site:raise HTTPException(404,'找不到网站') site['settings']=json.loads(site.pop('settings_json')) return site def get_entry(site_id,eid): val=db.one('SELECT * FROM entries WHERE site_id=? AND id=?',(site_id,eid)) if not val:raise HTTPException(404,'找不到内容') return entry_dict(val) def fail(exc): if isinstance(exc,HTTPException):raise exc raise HTTPException(400,detail=str(exc)[:500]) @app.get('/',response_class=HTMLResponse) def home(): return (ROOT/'app'/'templates'/'workspace.html').read_text(encoding='utf-8') @app.get('/classic',response_class=HTMLResponse) def classic_home(): return (ROOT/'app'/'templates'/'admin.html').read_text(encoding='utf-8') @app.get('/editor/{site_id}/{entry_id}',response_class=HTMLResponse) def visual_editor(site_id:str,entry_id:str): get_entry(site_id,entry_id) return (ROOT/'app'/'templates'/'editor.html').read_text(encoding='utf-8') @app.get('/api/health') def health():return {'status':'ok','name':'SiteForge','version':VERSION,'commit':BUILD_SHA} @app.get('/api/ready') def ready(): from .healthchecks import readiness ok=readiness() return JSONResponse({'status':'ready' if ok else 'unavailable'},status_code=200 if ok else 503) @app.get('/api/themes') def themes(): manifests=[] for theme in THEMES: meta=json.loads((ROOT/'themes'/theme/'manifest.json').read_text(encoding='utf-8')) manifests.append(meta) return manifests @app.get('/api/editor/blocks') def editor_blocks(): return {'blocks':blueprints.BLOCKS,'bindings':blueprints.BINDABLE,'page_templates':blueprints.PAGE_TEMPLATES,'property_schemas':blueprints.BLOCK_PROPERTY_SCHEMA} @app.get('/api/sites') def list_sites(): arr=db.rows('SELECT * FROM sites ORDER BY updated_at DESC') for s in arr: s.pop('settings_json',None) s['entries_count']=db.one('SELECT count(*) n FROM entries WHERE site_id=?',(s['id'],))['n'] s['build_count']=db.one('SELECT count(*) n FROM builds WHERE site_id=?',(s['id'],))['n'] return arr @app.post('/api/sites',status_code=201) def create_site(payload:SiteIn): if payload.theme not in theme_store.ids():raise HTTPException(400,'没有该模板') try:clean_hex(payload.accent) except Exception as exc:fail(exc) site_id=uid() settings={'origin':payload.origin,'nav':[],'email':'','footer_text':''} homeblocks=blueprints.theme_blocks(payload.theme,'home') if payload.preset_id: p=db.one('SELECT * FROM presets WHERE id=?',(payload.preset_id,)) if not p:raise HTTPException(404,'模板预设不存在') payload.theme=p['theme'];settings.update(json.loads(p['settings_json'])) homeblocks=json.loads(p['home_blocks_json']) with db.connect() as c: c.execute('''INSERT INTO sites(id,name,domain,tagline,theme,accent,logo,mode,settings_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)''', (site_id,payload.name,payload.domain,payload.tagline,payload.theme,payload.accent,payload.logo,payload.mode,json.dumps(settings,ensure_ascii=False),db.now(),db.now())) save_entry(site_id,{'type':'page','title':'首页','route':'/','summary':payload.tagline, 'seo_title':payload.name,'meta':{'blocks':homeblocks}},source='starter') return get_site(site_id) @app.get('/api/sites/{site_id}') def site_detail(site_id:str): site=get_site(site_id) site['counts']={r['type']:r['n'] for r in db.rows('SELECT type,count(*) n FROM entries WHERE site_id=? GROUP BY type',(site_id,))} site['builds']=db.rows('SELECT id,created_at,total_pages,status FROM builds WHERE site_id=? ORDER BY created_at DESC LIMIT 10',(site_id,)) return site @app.patch('/api/sites/{site_id}') def patch_site(site_id:str,patch:SitePatch): old=get_site(site_id) data=patch.model_dump(exclude_unset=True) if 'theme' in data and data['theme'] not in theme_store.ids():raise HTTPException(400,'没有该模板') if 'accent' in data: try:clean_hex(data['accent']) except Exception as exc:fail(exc) settings=old['settings'] if 'origin' in data:settings['origin']=data.pop('origin') or '' valid=['name','domain','tagline','theme','accent','logo','mode'] fields=[k for k in data if k in valid] with db.connect() as c: for k in fields:c.execute(f'UPDATE sites SET {k}=? WHERE id=?',(data[k],site_id)) c.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings,ensure_ascii=False),db.now(),site_id)) return get_site(site_id) @app.delete('/api/sites/{site_id}') def delete_site(site_id:str): get_site(site_id) db.execute('DELETE FROM sites WHERE id=?',(site_id,)) for relative in (db.DATA/'media'/site_id,db.DATA/'builds'/site_id): if relative.exists():shutil.rmtree(relative) for z in (db.DATA/'archives').glob(site_id+'-*.zip') if (db.DATA/'archives').exists() else []: z.unlink() return {'deleted':True} @app.post('/api/sites/{site_id}/clone',status_code=201) def clone_site(site_id:str,settings:dict[str,Any]=Body(default_factory=dict)): original=get_site(site_id) clone=create_site(SiteIn(name=settings.get('name') or original['name']+' 副本',theme=original['theme'], domain=settings.get('domain',''),tagline=original['tagline'],accent=original['accent'],logo=original['logo'])) entries=db.rows('SELECT * FROM entries WHERE site_id=?',(site_id,)) for old in entries: e=entry_dict(old) try:save_entry(clone['id'],e,source='clone',replace_route=True) except Exception:pass return get_site(clone['id']) @app.get('/api/sites/{site_id}/entries') def list_entries(site_id:str,typ:str='',q:str=''): get_site(site_id) sql='SELECT * FROM entries WHERE site_id=?';params=[site_id] if typ:sql+=' AND type=?';params.append(typ) if q:sql+=' AND (title LIKE ? OR route LIKE ?)';params.extend(['%'+q+'%','%'+q+'%']) sql+=' ORDER BY CASE WHEN route="/" THEN 0 ELSE 1 END, type, updated_at DESC' return [entry_dict(x) for x in db.rows(sql,params)] @app.post('/api/sites/{site_id}/entries',status_code=201) def create_entry(site_id:str,payload:dict[str,Any]): try: eid=save_entry(site_id,payload) return get_entry(site_id,eid) except Exception as exc:fail(exc) @app.patch('/api/sites/{site_id}/entries/{entry_id}') def update_entry(site_id:str,entry_id:str,payload:dict[str,Any]): previous=get_entry(site_id,entry_id) if isinstance(payload.get('meta'),dict) and any(k in payload['meta'] for k in ('blocks','layout_mode','page_template')): site_design_v87.require_legacy_enabled() data={**previous,**payload} try: save_entry(site_id,data,existing_id=entry_id) return get_entry(site_id,entry_id) except Exception as exc:fail(exc) @app.delete('/api/sites/{site_id}/entries/{entry_id}') def delete_entry(site_id:str,entry_id:str): get_entry(site_id,entry_id) db.execute('DELETE FROM entries WHERE site_id=? AND id=?',(site_id,entry_id)) return {'deleted':True} def infer_page_template(entry): route=str(entry.get('route','')).lower() typ=entry.get('type') if route=='/':return 'home' if typ in ('product','vehicle'):return 'product' if typ=='post':return 'article' if typ=='brand':return 'brand' if any(k in route for k in ('/about', '/company', '/our-story')):return 'about' if any(k in route for k in ('/contact', '/inquiry')):return 'contact' return 'standard' @app.post('/api/sites/{site_id}/scaffold') def scaffold_standard_pages(site_id:str): """Add commonly needed enterprise pages while never overwriting existing URLs.""" site=get_site(site_id) defaults=[('about','关于我们','/about/','企业介绍、经营理念与核心服务能力'), ('contact','联系我们','/contact/','联系方式、业务咨询与合作对接'), ('standard','服务与解决方案','/services/','适合不同客户需求的业务与服务')] created=[];skipped=[] for kind,title,route,summary in defaults: if db.one('SELECT id FROM entries WHERE site_id=? AND route=?',(site_id,route)): skipped.append(route);continue meta={'page_template':kind,'blocks':blueprints.theme_blocks(site['theme'],kind,(theme_store.pinned(site_id) or {}).get('version'))} save_entry(site_id,{'type':'page','title':title,'route':route,'summary':summary,'meta':meta},source='scaffold') created.append(route) return {'created':created,'skipped':skipped,'note':'不覆盖已有页面或修改任何原始 URL'} @app.post('/api/sites/{site_id}/apply-theme') def apply_theme_templates(site_id:str,payload:dict=Body(default_factory=dict)): """Re-assemble existing content with the selected theme without modifying URLs/SEO.""" site=get_site(site_id) mode=payload.get('mode','missing') if mode not in ('missing','overwrite'):raise HTTPException(400,'无效的套版模式') entries=[entry_dict(x) for x in db.rows('SELECT * FROM entries WHERE site_id=?',(site_id,))] changed=0;skipped=0 for entry in entries: if mode=='missing' and entry['meta'].get('blocks'): skipped+=1;continue kind=infer_page_template(entry) entry['meta']={**entry['meta'],'page_template':kind,'blocks':blueprints.theme_blocks(site['theme'],kind,(theme_store.pinned(site_id) or {}).get('version'))} save_entry(site_id,entry,existing_id=entry['id']) changed+=1 return {'updated':changed,'skipped':skipped,'total':len(entries),'theme':site['theme'],'mode':mode, 'note':'只修改页面模板/区块,不改变正文原数据、URL、SEO 和 Canonical'} @app.get('/api/sites/{site_id}/schemas') def site_schemas(site_id:str): get_site(site_id) rows=db.rows('SELECT content_type,fields_json FROM content_schemas WHERE site_id=?',(site_id,)) result={k:v for k,v in blueprints.CONTENT_MODELS.items()} for row in rows:result[row['content_type']]=json.loads(row['fields_json']) return result @app.put('/api/sites/{site_id}/schemas/{kind}') def update_schema(site_id:str,kind:str,payload:list[dict]=Body(...)): get_site(site_id) if kind not in TYPES:raise HTTPException(400,'未知内容类型') if len(payload)>40:raise HTTPException(400,'最多允许40个自定义字段') clean=[];names=set() for field in payload: name=str(field.get('name') or '') if not re.fullmatch('[a-zA-Z_][a-zA-Z0-9_]{0,50}',name) or name in names:raise HTTPException(400,'字段名称不合法或重复') typ=field.get('type','text') if typ not in ('text','textarea','number','date','url','boolean'):raise HTTPException(400,'不支持该字段类型') names.add(name);clean.append({'name':name,'label':str(field.get('label') or name)[:80],'type':typ}) with db.connect() as conn: conn.execute('INSERT INTO content_schemas(site_id,content_type,fields_json) VALUES (?,?,?) ON CONFLICT(site_id,content_type) DO UPDATE SET fields_json=excluded.fields_json',(site_id,kind,json.dumps(clean,ensure_ascii=False))) return {'content_type':kind,'fields':clean} @app.get('/api/sites/{site_id}/pages/{entry_id}/blueprint') def read_blueprint(site_id:str,entry_id:str): entry=get_entry(site_id,entry_id) kind=entry['meta'].get('page_template') or ('home' if entry['route']=='/' else 'product' if entry['type'] in ('product','vehicle') else 'article' if entry['type']=='post' else 'standard') return {'entry_id':entry_id,'page_template':kind,'layout_mode':entry['meta'].get('layout_mode','append'),'blocks':entry['meta'].get('blocks',[]),'entry':entry} class BlueprintPayload(BaseModel): blocks:list[dict] page_template:str='standard' layout_mode:str='append' site_design:dict|None=None @app.put('/api/sites/{site_id}/pages/{entry_id}/blueprint') def save_blueprint(site_id:str,entry_id:str,payload:BlueprintPayload): site_design_v87.require_legacy_enabled() entry=get_entry(site_id,entry_id) if payload.page_template not in blueprints.PAGE_TEMPLATES:raise HTTPException(400,'不支持的页面模板') try:validated=blueprints.validate_blocks(payload.blocks) except ValueError as exc:raise HTTPException(400,str(exc)) if payload.layout_mode not in ('append','replace'):raise HTTPException(400,'不支持的页面设计模式') meta={**entry['meta'],'page_template':payload.page_template,'layout_mode':payload.layout_mode,'blocks':validated} save_entry(site_id,{**entry,'meta':meta},existing_id=entry_id) return {'saved':True,'page_template':payload.page_template,'layout_mode':payload.layout_mode,'blocks':validated} @app.post('/api/sites/{site_id}/pages/{entry_id}/blueprint/preset') def reset_blueprint(site_id:str,entry_id:str,payload:dict=Body(default_factory=dict)): site_design_v87.require_legacy_enabled() entry=get_entry(site_id,entry_id) kind=payload.get('page_template','home' if entry['route']=='/' else 'standard') if kind not in blueprints.PAGE_TEMPLATES:raise HTTPException(400,'未知的页面模板') site=get_site(site_id) blocks=blueprints.theme_blocks(site['theme'],kind,(theme_store.pinned(site_id) or {}).get('version')) meta={**entry['meta'],'blocks':blocks,'page_template':kind} save_entry(site_id,{**entry,'meta':meta},existing_id=entry_id) return {'page_template':kind,'blocks':blocks} @app.post('/api/sites/{site_id}/pages/{entry_id}/editor-preview',response_class=HTMLResponse) def editor_preview(site_id:str,entry_id:str,payload:BlueprintPayload): site=get_site(site_id);page=get_entry(site_id,entry_id) try:blocks=blueprints.validate_blocks(payload.blocks) except (ValueError,TypeError) as exc:raise HTTPException(400,str(exc)) site['schemas']={k:v for k,v in blueprints.CONTENT_MODELS.items()} for row in db.rows('SELECT content_type,fields_json FROM content_schemas WHERE site_id=?',(site_id,)): site['schemas'][row['content_type']]=json.loads(row['fields_json']) content=blueprints.render_blocks(blocks,site,page,[entry_dict(x) for x in db.rows("SELECT * FROM entries WHERE site_id=? AND status='published'",(site_id,))]) # The editor now uses the same trusted theme renderer as production builds. # CSS assets are inlined because srcdoc is sandboxed and has no /assets mount. from . import theme_pro, theme_store, responsive import re as _re from markupsafe import Markup from jinja2 import Environment,FileSystemLoader,select_autoescape settings=site.get('settings') or {} if payload.site_design is not None: settings={**settings,'site_design':site_design_v87.validate(payload.site_design)} if payload.site_design is not None: settings['design_tokens']=settings['site_design']['tokens'] site['settings_json']=json.dumps(settings,ensure_ascii=False) records=db.rows("SELECT * FROM entries WHERE site_id=? AND status='published'",(site_id,)) page['meta_json']=json.dumps({**page['meta'],'layout_mode':payload.layout_mode},ensure_ascii=False) data=theme_store.pinned(site_id) theme_dir=theme_store.resolve(site['theme'],data['version'] if data else None) if site['theme'] in theme_pro.THEMES and theme_dir.resolve()==(ROOT/'themes'/site['theme']).resolve(): html=theme_pro.render(site['theme'],site,page,records,blocks_html=content) elif (theme_dir/'pro.html').exists(): env=Environment(loader=FileSystemLoader([str(theme_dir),str(ROOT/'themes')]),autoescape=select_autoescape(['html'])) ctx=theme_pro.make_context(site,page,records) ctx.update(custom_blocks=Markup(content),has_custom=bool(content),noindex=False, designer_replace=payload.layout_mode=='replace',theme_overrides=True) html=env.get_template('pro.html').render(**ctx) else: html='
'+content+'' styles='\n'.join([ (ROOT/'app'/'static'/'pro-base.css').read_text(encoding='utf-8'), (ROOT/'app'/'static'/'commercial.css').read_text(encoding='utf-8'), (ROOT/'app'/'static'/'blocks.css').read_text(encoding='utf-8'), (ROOT/'app'/'static'/'factory.css').read_text(encoding='utf-8'), (theme_dir/'pro.css').read_text(encoding='utf-8') if (theme_dir/'pro.css').exists() else '', (theme_dir/'theme.css').read_text(encoding='utf-8'), responsive.editor_css(blocks), site_design_v87.responsive_css(site_design_v87.published(settings)), site_design_v87.token_css(site_design_v87.published(settings)['tokens'])]) html=_re.sub(r']*>', '', html) html=_re.sub(r'', '', html) html=html.replace('','') bridge = r'''''' # The static site's /media/ assets are hosted by the admin at # /local-media/{site}/. Rewrite only the editor response, never builds. html=html.replace('src="/media/',f'src="/local-media/{site_id}/') html=html.replace('poster="/media/',f'poster="/local-media/{site_id}/') return HTMLResponse(html.replace('