2026-10-22 16:49:25 +08:00
"""Static site assembly, audit, ZIP export, immutable build versions."""
import json
import re
import shutil
import hashlib
import mimetypes
import zipfile
from pathlib import Path
from urllib.parse import urlparse , urljoin
from html import escape
from xml.sax.saxutils import escape as xml_escape
from bs4 import BeautifulSoup
import httpx
from . import safe_http
from jinja2 import Environment , FileSystemLoader , select_autoescape
from markupsafe import Markup
2026-10-23 12:31:03 +08:00
from . import db , blueprints , theme_store , static_site , responsive , theme_pro , site_design_v87 , multilingual
2026-10-22 16:49:25 +08:00
from .core import uid , route_path , ensure_public_url , sanitize , THEMES
ROOT = Path ( __file__ ) . resolve () . parent . parent
THEME_DIR = ROOT / 'themes'
ENV = Environment ( loader = FileSystemLoader ( str ( THEME_DIR )), autoescape = select_autoescape ([ 'html' , 'xml' ]))
TYPE_NAMES = { 'page' : '页面' , 'post' : '新闻' , 'product' : '产品' , 'service' : '服务' , 'case' : '案例' , 'brand' : '品牌' , 'vehicle' : '车型' , 'other' : '内容' }
2026-10-23 12:31:03 +08:00
def copy_blocks_css ( theme_dir , dist_assets ):
"""Theme-provided blocks.css wins; platform default is the fallback."""
candidate = theme_dir / 'blocks.css'
src = candidate if candidate . is_file () else ROOT / 'app' / 'static' / 'blocks.css'
shutil . copy2 ( src , dist_assets / 'blocks.css' )
2026-10-22 16:49:25 +08:00
def output_path ( dist , route ):
path = route_path ( route )
if path == '/' : return dist / 'index.html'
clean = path . lstrip ( '/' )
# Treat explicit .html/.htm as files and other endpoints as directory/index.
if clean . lower () . endswith (( '.html' , '.htm' )):
out = dist / clean
else :
out = dist / clean / 'index.html'
target = out . resolve ()
if not target . is_relative_to ( dist . resolve ()): raise ValueError ( '非法生成目标目录' )
return out
def link ( path ):
return ( path or '/' ) . lstrip ( '/' ) or './'
def public_url ( site , route ):
origin = site [ 'domain' ] . strip () . rstrip ( '/' )
if not origin . startswith (( 'https://' , 'http://' )): return ''
return origin + route
def render_blocks ( blocks ):
parts = []
if not isinstance ( blocks , list ): return ''
for block in blocks [: 30 ]:
if not isinstance ( block , dict ): continue
typ = str ( block . get ( 'type' , 'text' ))
title = escape ( str ( block . get ( 'title' , '' )))
sub = escape ( str ( block . get ( 'subtitle' , '' )))
body = sanitize ( str ( block . get ( 'body' , '' )))
if typ == 'hero' :
parts . append ( f '<section class="block-hero"><div class="container"><span class="eyebrow"> { sub } </span><h1> { title } </h1><p> { escape ( str ( block . get ( "description" , "" ))) } </p></div></section>' )
elif typ == 'features' :
features = block . get ( 'items' ,[])
cards = '' . join ( f '<div class="feature"><div class="feature-mark">0 { i + 1 } </div><h3> { escape ( str ( x . get ( "title" , "" ))) } </h3><p> { escape ( str ( x . get ( "text" , "" ))) } </p></div>' for i , x in enumerate ( features [: 12 ]) if isinstance ( x , dict ))
parts . append ( f '<section class="section"><div class="container"><div class="section-heading"><h2> { title } </h2></div><div class="feature-grid"> { cards } </div></div></section>' )
elif typ == 'text' :
parts . append ( f '<section class="section"><div class="container prose"><h2> { title } </h2> { body } </div></section>' )
elif typ == 'stats' :
vals = '' . join ( f '<div class="stat"><strong> { escape ( str ( x . get ( "value" , "" ))) } </strong><span> { escape ( str ( x . get ( "label" , "" ))) } </span></div>' for x in block . get ( 'items' ,[])[: 8 ] if isinstance ( x , dict ))
parts . append ( f '<section class="section stats-section"><div class="container stats"> { vals } </div></section>' )
elif typ == 'cta' :
target = block . get ( 'href' , '/contact/' )
try : target = link ( route_path ( target ))
except ValueError : target = './'
parts . append ( f '<section class="section"><div class="container cta"><h2> { title } </h2><p> { sub } </p><a class="btn" href=" { escape ( target , quote = True ) } "> { escape ( str ( block . get ( "button" , "Contact us" ))) } </a></div></section>' )
return '' . join ( parts )
def url_fix ( link_value , origin_host , known_routes ):
"""Convert imported internal absolute URLs to static-site-relative links."""
if not link_value : return link_value
parsed = urlparse ( link_value )
if parsed . scheme in ( 'mailto' , 'tel' ): return link_value
if parsed . scheme in ( 'https' , 'http' ) and parsed . hostname == origin_host and parsed . path in known_routes :
return link ( parsed . path ) + (( '?' + parsed . query ) if parsed . query else '' )
if not parsed . scheme and link_value . startswith ( '/' ) and parsed . path in known_routes :
return link ( parsed . path )
return link_value
def rewrite_internal_links ( body , origin_host , known_routes ):
soup = BeautifulSoup ( body or '' , 'html.parser' )
for a in soup . find_all ( 'a' , href = True ):
a [ 'href' ] = url_fix ( a [ 'href' ], origin_host , known_routes )
return str ( soup )
def copy_media ( site_id , dist ):
src = db . DATA / 'media' / site_id
target = dist / 'media'
if src . exists (): shutil . copytree ( src , target , dirs_exist_ok = True )
legacy = db . DATA / 'legacy' / site_id
if legacy . exists (): shutil . copytree ( legacy , dist , dirs_exist_ok = True )
def normalize_local_images ( html ):
soup = BeautifulSoup ( html or '' , 'html.parser' )
for tag in soup . find_all ( 'img' , src = True ):
src = tag [ 'src' ]
if src . startswith ( 'media/' ):
tag [ 'src' ] = '/' + src
# Keep /media/... absolute to the deployed site root, including in nested pages.
return str ( soup )
def mirror_images ( html , dist , origin_host , client , cache , warnings ):
soup = BeautifulSoup ( html or '' , 'html.parser' )
for tag in soup . find_all ( 'img' , src = True ):
tag [ 'src' ] = mirror_image_url ( tag [ 'src' ], dist , origin_host , client , cache , warnings )
return str ( soup )
def mirror_image_url ( raw , dist , origin_host , client , cache , warnings ):
if not raw : return raw
if raw . startswith ( '/media/' ): return raw # already localized, root-relative static asset
parsed = urlparse ( raw )
if parsed . scheme not in ( 'http' , 'https' ):
if raw . startswith ( '/' ):
return raw . lstrip ( '/' )
return raw
if raw in cache : return cache [ raw ]
try :
ensure_public_url ( raw )
response = safe_http . fetch ( raw , max_bytes = 8_000_000 , timeout = 10 , redirects = 0 )
response . raise_for_status ()
mime = response . headers . get ( 'content-type' , '' ) . split ( ';' )[ 0 ] . lower ()
allowed = { 'image/jpeg' : '.jpg' , 'image/png' : '.png' , 'image/webp' : '.webp' , 'image/gif' : '.gif' , 'image/svg+xml' : '.svg' , 'image/avif' : '.avif' }
if mime not in allowed : raise ValueError ( f '图片类型不支持: { mime } ' )
buf = response . content
# SVG can have scripts; disallow external SVG for safety.
if mime == 'image/svg+xml' : raise ValueError ( 'SVG 外部资源需人工审查' )
orig = parsed . path . lstrip ( '/' )
# Original path is preserved for same-domain assets with safe extensions.
if parsed . hostname == origin_host and orig and re . fullmatch ( r '[\w\-./%]+' , orig ) and not any ( p in ( '.' , '..' ) for p in orig . split ( '/' )) and Path ( orig ) . suffix . lower () in allowed . values ():
rel = orig
else :
rel = 'media/remote-' + hashlib . sha256 ( raw . encode ()) . hexdigest ()[: 16 ] + allowed [ mime ]
dst = dist / rel
if not dst . resolve () . is_relative_to ( dist . resolve ()): raise ValueError ( '图片路径越界' )
dst . parent . mkdir ( parents = True , exist_ok = True )
dst . write_bytes ( buf )
cache [ raw ] = rel
return rel
except Exception as exc :
warnings . append ( f '外部图片未本地化: { raw [: 100 ] } ( { str ( exc )[: 120 ] } )' )
return raw
def emit_nginx ( dist , redirects , slashless ):
# Include snippets to be inserted inside the real site's `server {}` block.
lines = [ '# SiteForge 生成:将本文件内容合并到 Nginx server {} 内' , 'index index.html;' , 'error_page 404 /404.html;' ,
'location / { try_files $uri $uri/ =404; }' ]
for old , new , code in redirects :
lines . append ( f 'location = { old } {{ return { int ( code ) } { new } ; }} ' )
for route in slashless :
lines . append ( f 'location = { route } {{ try_files { route } /index.html =404; }} ' )
( dist / 'nginx-snippet.conf' ) . write_text ( ' \n ' . join ( lines ) + ' \n ' , encoding = 'utf-8' )
def build ( site_id , mirror = False ):
site = db . one ( 'SELECT * FROM sites WHERE id=?' ,( site_id ,))
if not site : raise ValueError ( '网站不存在' )
if site [ 'theme' ] not in theme_store . ids (): raise ValueError ( '无效的主题' )
2026-10-23 12:31:03 +08:00
multilingual . assert_build_safe ( site_id )
locale = multilingual . site_locale ( site_id )
language_alternates = multilingual . alternates ( site_id )
2026-10-22 16:49:25 +08:00
records = db . rows ( "SELECT * FROM entries WHERE site_id=? AND status='published' ORDER BY type,title" ,( site_id ,))
redirects = db . rows ( 'SELECT * FROM redirects WHERE site_id=?' ,( site_id ,))
settings = json . loads ( site [ 'settings_json' ] or ' {} ' )
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' ])
site [ 'email' ] = settings . get ( 'email' , '' )
site [ 'footer_text' ] = settings . get ( 'footer_text' , '' )
routes = { e [ 'route' ] for e in records }
issues = []; warnings = []; slashless = []
if '/' not in routes : warnings . append ( '缺少 / 首页:已自动生成默认首页' )
if not site [ 'domain' ] . startswith (( 'http://' , 'https://' )): warnings . append ( '尚未配置完整域名:未生成绝对 canonical' )
route_set = set ( routes )
reserved = { '/sitemap.xml' , '/robots.txt' , '/404.html' }
for e in records :
if e [ 'route' ] in reserved : issues . append ( '页面路由与系统文件冲突: ' + e [ 'route' ])
if e [ 'route' ] != '/' and not e [ 'route' ] . endswith ( '/' ) and not e [ 'route' ] . endswith (( '.html' , '.htm' )):
slashless . append ( e [ 'route' ])
if any ( r [ 'old_path' ] in route_set for r in redirects ): issues . append ( '重定向来源和有效页面冲突' )
if issues : raise ValueError ( '构建失败:' + '; ' . join ( issues ))
bid = uid ()
dist = db . DATA / 'builds' / site_id / bid / 'dist'
dist . mkdir ( parents = True , exist_ok = True )
copy_media ( site_id , dist )
( dist / 'assets' ) . mkdir ( exist_ok = True )
theme_pin = theme_store . pinned ( site_id )
theme_dir = theme_store . resolve ( site [ 'theme' ], theme_pin [ 'version' ] if theme_pin and theme_pin [ 'theme_id' ] == site [ 'theme' ] else None )
shutil . copy2 ( theme_dir / 'theme.css' , dist / 'assets' / 'theme.css' )
2026-10-22 17:49:12 +08:00
copy_blocks_css ( theme_dir , dist / 'assets' )
2026-10-22 16:49:25 +08:00
shutil . copy2 ( ROOT / 'app' / 'static' / 'pro-base.css' , dist / 'assets' / 'pro-base.css' )
shutil . copy2 ( ROOT / 'app' / 'static' / 'commercial.css' , dist / 'assets' / 'commercial.css' )
visuals = theme_dir / 'visual'
if visuals . is_dir ():
shutil . copytree ( visuals , dist / 'assets' / 'visual' , dirs_exist_ok = True )
shutil . copy2 ( ROOT / 'app' / 'static' / 'pro.js' , dist / 'assets' / 'pro.js' )
if ( theme_dir / 'pro.css' ) . exists ():
shutil . copy2 ( theme_dir / 'pro.css' , dist / 'assets' / 'pro.css' )
else :
( dist / 'assets' / 'pro.css' ) . write_text ( '' , encoding = 'utf-8' )
accent = site [ 'accent' ] if re . fullmatch ( r '#[0-9a-fA-F] {6} ' , site [ 'accent' ] or '' ) else '#2563eb'
tokens = settings . get ( 'design_tokens' ) or {}
allowed_fonts = { 'system' : 'system-ui,-apple-system,BlinkMacSystemFont,sans-serif' ,
'serif' : 'Georgia,Times New Roman,serif' ,
'modern' : 'Arial,Helvetica,sans-serif' }
font = allowed_fonts . get ( tokens . get ( 'font' ), 'system-ui,-apple-system,sans-serif' )
radius = int ( max ( 0 , min ( 30 , tokens . get ( 'radius' , 12 ))))
space = int ( max ( 32 , min ( 140 , tokens . get ( 'section_spacing' , 72 ))))
width = int ( max ( 900 , min ( 1600 , tokens . get ( 'container_width' , 1200 ))))
scale = float ( max ( .75 , min ( 1.5 , tokens . get ( 'heading_scale' , 1 ))))
button = { 'rounded' : f ' { radius } px' , 'pill' : '999px' , 'square' : '0px' } . get ( tokens . get ( 'button_style' ), f ' { radius } px' )
theme_css = ( f ':root {{ --accent: { accent } ;--sf-font: { font } ;--sf-radius: { radius } px;'
f '--sf-space: { space } px;--sf-width: { width } px;--sf-heading-scale: { scale } ; }} '
f 'body {{ font-family:var(--sf-font) }} .sf-canvas .sf-section,.sf-canvas .section {{ padding-block:var(--sf-space) }} '
f '.container,.sf-container {{ max-width:var(--sf-width) }} '
f '.btn,.primary,.request,.button,.nav-btn {{ background-color: { accent } ;border-radius: { button } }} '
f 'body .sf-canvas h1 {{ font-size:clamp(2.5rem,5vw,calc(4.8rem * var(--sf-heading-scale))) }} ' )
( dist / 'assets' / 'custom.css' ) . write_text ( theme_css + responsive . site_css ( records ) + site_design_v87 . responsive_css ( site_design_v87 . published ( settings )), encoding = 'utf-8' )
# For installed theme packages, Jinja loads only the trusted included template.
professional_template = ( theme_dir / 'pro.html' ) . exists ()
if professional_template and site [ 'theme' ] not in theme_pro . THEMES :
shutil . copy2 ( theme_dir / 'theme.css' , dist / 'assets' / 'theme-overrides.css' )
template = ENV . get_template ( site [ 'theme' ] + '/site.html' ) if site [ 'theme' ] in THEMES and theme_dir . resolve () == ( THEME_DIR / site [ 'theme' ]) . resolve () else Environment ( loader = FileSystemLoader ( str ( theme_dir )), autoescape = True ) . get_template ( 'site.html' )
orig = urlparse ( settings . get ( 'origin' ) or site [ 'domain' ])
origin_host = orig . hostname
local_records = [ dict ( e ) for e in records ]
remote_cache = {}
try :
with httpx . Client ( timeout = httpx . Timeout ( 10 , connect = 4 ), trust_env = False , headers = { 'User-Agent' : 'SiteForgeAssetMirror/0.1' }) as client :
for e in local_records :
e [ 'body' ] = normalize_local_images ( rewrite_internal_links ( e [ 'body' ], origin_host , route_set ))
if mirror :
e [ 'body' ] = mirror_images ( e [ 'body' ], dist , origin_host , client , remote_cache , warnings )
if e [ 'cover' ]:
e [ 'cover' ] = mirror_image_url ( e [ 'cover' ], dist , origin_host , client , remote_cache , warnings )
if mirror and site [ 'logo' ]:
site [ 'logo' ] = mirror_image_url ( site [ 'logo' ], dist , origin_host , client , remote_cache , warnings )
except Exception as exc :
warnings . append ( '资源镜像操作异常: ' + str ( exc ))
# Precompute immutable catalogue indexes once per build. 5,000+ detail pages
# must not repeatedly regroup every product and parse every metadata JSON.
prepared_catalogue = theme_pro . prepare ( local_records )
# Derive brand and category landing pages from actual content, not fabricated business data.
# Existing imported URLs always take priority; derived routes never rewrite source records.
derived = []
product_records = [ x for x in local_records if x . get ( 'type' ) in ( 'product' , 'vehicle' )]
for cat in ( x [ 'name' ] for x in prepared_catalogue [ 'categories' ]):
path = '/categories/' + theme_pro . slug ( cat ) + '/'
if path not in route_set :
derived . append ({ 'id' : 'derived-category' , 'route' : path , 'title' : cat , 'category' : cat , 'type' : 'page' ,
'summary' : '分类产品与相关信息' , 'body' : '' , 'cover' : '' , 'meta_json' : ' {} ' , 'seo_title' : '' , 'seo_description' : '' , 'canonical' : '' , 'robots' : 'index,follow' })
route_set . add ( path )
for brand in prepared_catalogue [ 'brands' ]:
if brand [ 'url' ] not in route_set :
derived . append ({ 'id' : 'derived-brand' , 'route' : brand [ 'url' ], 'title' : brand [ 'name' ], 'type' : 'brand' ,
'summary' : '相关品牌及产品' , 'body' : '' , 'cover' : '' , 'meta_json' : ' {} ' , 'seo_title' : '' , 'seo_description' : '' , 'canonical' : '' , 'robots' : 'index,follow' })
route_set . add ( brand [ 'url' ])
# Detail pages, including custom-type documents.
totals = 0
def render_one ( page , mode = 'detail' , listing_items = None ):
nonlocal totals
page = page or { 'title' : site [ 'name' ], 'route' : '/' , 'summary' : site [ 'tagline' ], 'body' : '' , 'cover' : '' , 'seo_title' : '' , 'seo_description' : '' , 'robots' : 'index,follow' , 'canonical' : '' , 'type' : 'page' , 'meta_json' : ' {} ' }
meta = json . loads ( page . get ( 'meta_json' ) or ' {} ' )
if not isinstance ( meta , dict ): meta = {}
defined_blocks = meta . get ( 'blocks' ,[])
use_blueprint = isinstance ( defined_blocks , list ) and len ( defined_blocks ) > 0
blocks_html = blueprints . render_blocks ( defined_blocks , site , page , local_records ) if use_blueprint else ''
# Starter/scaffold preset blocks are layout defaults, not additional content;
# the full theme already renders the corresponding page family. Keep edited
# blocks, even on starter pages, so the editor remains effective.
if professional_template and use_blueprint and meta . get ( 'layout_mode' ) != 'replace' and page . get ( 'source' ) in ( 'starter' , 'site-bootstrap' , 'scaffold' ):
kind = theme_pro . infer_kind ( page , mode )
try :
def signature ( rows ):
return [{ k : v for k , v in row . items () if k != 'id' } for row in rows ]
# Starter layouts may belong to any previously selected theme.
is_default = any ( signature ( defined_blocks ) == signature ( blueprints . theme_blocks ( t , kind )) for t in theme_pro . THEMES )
if not is_default and page . get ( 'source' ) == 'site-bootstrap' and kind == 'listing' :
# Bootstrap customizes collection title/source only; native listing
# already renders that content without the duplicate block.
is_default = all ( b . get ( 'type' ) in ( 'collection' , 'spacer' ) for b in defined_blocks )
if is_default : blocks_html = ''
except ( ValueError , TypeError , KeyError ): pass
canon = page . get ( 'canonical' ) or public_url ( site , page [ 'route' ])
if professional_template :
# Version-resolved trusted templates; uploaded themes cannot execute arbitrary code.
if site [ 'theme' ] in theme_pro . THEMES and theme_dir . resolve () == ( THEME_DIR / site [ 'theme' ]) . resolve ():
html = theme_pro . render ( site [ 'theme' ], site , page , local_records , mode , listing_items , blocks_html , prepared = prepared_catalogue )
else :
env = Environment ( loader = FileSystemLoader ([ str ( theme_dir ), str ( THEME_DIR )]), autoescape = select_autoescape ([ 'html' ]))
ctx = theme_pro . make_context ( site , page , local_records , mode , listing_items , prepared = prepared_catalogue )
ctx . update ( custom_blocks = Markup ( blocks_html ), has_custom = bool ( blocks_html ), noindex = ( ctx [ 'kind' ] == 'notfound' ), theme_overrides = True , designer_replace = meta . get ( 'layout_mode' ) == 'replace' )
html = env . get_template ( 'pro.html' ) . render ( ** ctx )
else :
html = template . render ( site = site , page = page , mode = mode , content = Markup ( page . get ( 'body' ) or '' ),
blocks = Markup ( blocks_html ), blueprint_mode = use_blueprint , entries = local_records , listing_items = listing_items or [],
type_names = TYPE_NAMES , nav = settings . get ( 'nav' ) or [ e for e in local_records if e [ 'type' ] == 'page' and e [ 'route' ] in ( '/' , '/about/' , '/about-us/' , '/contact/' , '/contact-us/' , '/services/' , '/products/' )],
link = link , asset = lambda x : x . lstrip ( '/' ) if x . startswith ( '/' ) else x , canonical = canon , seo_title = page . get ( 'seo_title' ) or page [ 'title' ],
seo_description = page . get ( 'seo_description' ) or page . get ( 'summary' ) or site [ 'tagline' ])
html = static_site . shell ( html , site , page , local_records , settings . get ( 'nav' ) or [])
# Each page supplies a stable selector to scope responsive rules in
# global custom.css. Reused templates can share block IDs safely.
html = __import__ ( 're' ) . sub ( r '<body(?=[\s>])' ,
'<body data-siteforge-route="' + responsive . route_key ( page [ 'route' ]) + '"' , html , count = 1 , flags = __import__ ( 're' ) . I )
2026-10-23 12:31:03 +08:00
html = multilingual . inject_seo ( html , locale , language_alternates . get ( page [ 'route' ],[]) if 'noindex' not in ( page . get ( 'robots' ) or '' ) . lower () else [])
2026-10-22 16:49:25 +08:00
target = output_path ( dist , page [ 'route' ])
if target . exists (): raise ValueError ( '多个路由写入同一文件: ' + page [ 'route' ])
target . parent . mkdir ( parents = True , exist_ok = True )
target . write_text ( html , encoding = 'utf-8' )
totals += 1
# Static pagination: large product collections must not embed 5,000 cards
# in one HTML page. Every item remains reachable, including in the sitemap.
LIST_PAGE_SIZE = 100
def render_paginated ( page , items ):
root_route = page [ 'route' ] . rstrip ( '/' ) + '/'
pages = max ( 1 ,( len ( items ) + LIST_PAGE_SIZE - 1 ) // LIST_PAGE_SIZE )
for number in range ( 1 , pages + 1 ):
current = dict ( page )
current [ 'route' ] = root_route if number == 1 else root_route + 'page/' + str ( number ) + '/'
if current [ 'route' ] in route_set and current [ 'route' ] != root_route :
raise ValueError ( '自动分页 URL 与原始内容冲突:' + current [ 'route' ])
current [ '_pagination' ] = { 'page' : number , 'pages' : pages ,
'previous' :( root_route if number == 2 else root_route + 'page/' + str ( number - 1 ) + '/' ) if number > 1 else '' ,
'next' : root_route + 'page/' + str ( number + 1 ) + '/' if number < pages else '' }
if number > 1 :
current [ 'title' ] = str ( page . get ( 'title' , '' )) + ' · 第 ' + str ( number ) + ' 页'
current [ 'seo_description' ] = str ( page . get ( 'summary' ) or page . get ( 'title' ) or '' ) + ' · 第 ' + str ( number ) + ' 页'
render_one ( current , 'listing' , items [( number - 1 ) * LIST_PAGE_SIZE : number * LIST_PAGE_SIZE ])
route_set . add ( current [ 'route' ])
for e in local_records : render_one ( e , 'home' if e [ 'route' ] == '/' else 'detail' )
category_items = {}
for item in product_records :
category = str ( theme_pro . fields ( item ) . get ( 'category' ) or '' ) . strip () . casefold ()
if category : category_items . setdefault ( category ,[]) . append ( item )
for e in derived :
if e . get ( 'category' ): render_paginated ( e , category_items . get ( e [ 'category' ] . casefold (),[]))
else : render_one ( e , 'detail' )
if '/' not in routes : render_one ( None , 'home' )
for typ in sorted ({ e [ 'type' ] for e in local_records if e [ 'type' ] != 'page' } | { 'product' }):
list_route = '/' + typ + '/'
if list_route in route_set : continue
itemlist = [ e for e in local_records if e [ 'type' ] == typ ]
render_paginated ({ 'type' : 'page' , 'route' : list_route , 'title' : TYPE_NAMES . get ( typ , typ ) + '展示' ,
'summary' : 'Explore our ' + typ , 'meta_json' : ' {} ' }, itemlist )
# Theme links must never lead to 404 simply because a new site has not
# entered its first products or company profile. These are explicitly
# noindex *empty placeholders*, not fabricated imported content.
generated_noindex = set ()
required_sections = {
'/products/' :( '产品展示' , 'listing' ), '/brands/' :( '品牌中心' , 'listing' ),
'/news/' :( '新闻资讯' , 'listing' ), '/cases/' :( '项目案例' , 'listing' ),
'/about/' :( '关于我们' , 'detail' ), '/contact/' :( '联系我们' , 'detail' ),
'/services/' :( '服务介绍' , 'listing' ), '/faq/' :( '常见问题' , 'detail' ),
}
for route ,( title , mode ) in required_sections . items ():
if route in route_set : continue
page = { 'title' : title , 'route' : route , 'type' : 'page' ,
'summary' : '请在后台填写真实企业资料及栏目内容后再开放收录。' ,
'body' : '' , 'cover' : '' , 'meta_json' : ' {} ' , 'robots' : 'noindex,follow' }
if route == '/products/' :
# Standard product hub is also paginated. Otherwise thousands of
# vehicles would still be embedded into one generated scaffold page.
render_paginated ( page , product_records )
else :
render_one ( page , mode )
route_set . add ( route )
generated_noindex . add ( route )
if generated_noindex :
warnings . append ( f ' { len ( generated_noindex ) } 个尚无真实内容的标准栏目已生成 noindex 占位页,发布前请补充。' )
notfound = { 'title' : '404 · 页面不存在' , 'route' : '/404.html' , 'summary' : '' , 'body' : '' , 'cover' : '' , 'meta_json' : ' {} ' , 'robots' : 'noindex,follow' }
if professional_template :
if site [ 'theme' ] in theme_pro . THEMES and theme_dir . resolve () == ( THEME_DIR / site [ 'theme' ]) . resolve ():
missing_html = theme_pro . render ( site [ 'theme' ], site , notfound , local_records , 'notfound' , prepared = prepared_catalogue )
else :
env = Environment ( loader = FileSystemLoader ([ str ( theme_dir ), str ( THEME_DIR )]), autoescape = select_autoescape ([ 'html' ]))
ctx = theme_pro . make_context ( site , notfound , local_records , 'notfound' , prepared = prepared_catalogue )
ctx . update ( custom_blocks = Markup ( '' ), has_custom = False , noindex = True , theme_overrides = True )
missing_html = env . get_template ( 'pro.html' ) . render ( ** ctx )
else :
missing_html = template . render ( site = site , page = notfound , mode = 'notfound' , content = '' , blocks = '' , blueprint_mode = False , entries = local_records , listing_items = [], type_names = TYPE_NAMES , nav = [], link = link , seo_title = '404' , seo_description = 'Page not found' , canonical = '' )
2026-10-23 12:31:03 +08:00
missing_html = multilingual . inject_seo ( missing_html , locale ,[])
2026-10-22 16:49:25 +08:00
( dist / '404.html' ) . write_text ( missing_html , encoding = 'utf-8' )
# A complete static search page is packaged for every theme, not a live API.
static_site . write_assets ( dist , local_records )
if '/search/' not in route_set :
search_file = dist / 'search' / 'index.html'
search_file . parent . mkdir ( parents = True , exist_ok = True )
search_entry = { 'title' : '站内搜索' , 'route' : '/search/' , 'type' : 'page' , 'summary' : '搜索本站内容' , 'body' : '' , 'meta_json' : ' {} ' , 'robots' : 'noindex,follow' }
if professional_template :
if site [ 'theme' ] in theme_pro . THEMES and theme_dir . resolve () == ( THEME_DIR / site [ 'theme' ]) . resolve ():
search_html = theme_pro . render ( site [ 'theme' ], site , search_entry , local_records , prepared = prepared_catalogue )
else :
env = Environment ( loader = FileSystemLoader ([ str ( theme_dir ), str ( THEME_DIR )]), autoescape = select_autoescape ([ 'html' ]))
ctx = theme_pro . make_context ( site , search_entry , local_records , prepared = prepared_catalogue )
ctx . update ( custom_blocks = Markup ( '' ), has_custom = False , noindex = True , theme_overrides = True )
search_html = env . get_template ( 'pro.html' ) . render ( ** ctx )
else :
search_html = static_site . search_page ( site )
2026-10-23 12:31:03 +08:00
search_html = multilingual . inject_seo ( search_html , locale ,[])
2026-10-22 16:49:25 +08:00
search_file . write_text ( search_html , encoding = 'utf-8' )
route_set . add ( '/search/' )
noindex_routes = { e [ 'route' ] for e in records if 'noindex' in ( e [ 'robots' ] or '' ) . lower ()} | { '/search/' } | generated_noindex
canonical_routes = sorted ([ x for x in route_set if not x . startswith ( '/404' ) and x not in noindex_routes ])
if site [ 'domain' ] . startswith (( 'https://' , 'http://' )):
2026-10-23 12:31:03 +08:00
urls = ' \n ' . join ( '<url><loc>' + xml_escape ( public_url ( site , r )) + '</loc>' + multilingual . sitemap_alternates ( language_alternates . get ( r ,[])) + '</url>' for r in canonical_routes )
sitemap = '<?xml version="1.0" encoding="UTF-8"?> \n <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">' + urls + '</urlset>'
2026-10-22 16:49:25 +08:00
( dist / 'sitemap.xml' ) . write_text ( sitemap , encoding = 'utf-8' )
( dist / 'robots.txt' ) . write_text ( 'User-agent: * \n Allow: / \n ' + ( f 'Sitemap: { site [ "domain" ] . rstrip ( "/" ) } /sitemap.xml \n ' if site [ 'domain' ] . startswith ( 'http' ) else '' ), encoding = 'utf-8' )
rlist = []
for r in redirects :
old , new = route_path ( r [ 'old_path' ]), r [ 'new_path' ]
if not ( new . startswith ( '/' ) or new . startswith ( 'https://' )): raise ValueError ( '非法重定向目标' )
rlist . append (( old , new , int ( r [ 'status_code' ])))
emit_nginx ( dist , rlist , slashless )
( dist / '.htaccess' ) . write_text ( 'Options -MultiViews \n RewriteEngine On \n ' + '' . join ( f 'RewriteRule ^ { re . escape ( old . lstrip ( "/" )) } $ { new } [R= { code } ,L,NE] \n ' for old , new , code in rlist ), encoding = 'utf-8' )
# Evaluate packaged files; external src references are reported (not silently asserted migrated).
external = []; missing = []; broken_links = []; legacy_links = []; canonical_mismatches = []
target_host = urlparse ( site [ 'domain' ]) . hostname
for e in records :
c = urlparse ( e [ 'canonical' ] or '' )
if c . hostname and target_host and c . hostname != target_host :
canonical_mismatches . append ( e [ 'route' ])
for f in dist . rglob ( '*.html' ):
soup = BeautifulSoup ( f . read_text ( encoding = 'utf-8' ), 'html.parser' )
for a in soup . select ( 'a[href]' ):
href = a . get ( 'href' , '' )
p = urlparse ( href )
if p . scheme in ( 'http' , 'https' ):
if p . hostname == origin_host and p . path not in route_set and href not in legacy_links :
legacy_links . append ( href )
elif href and href not in ( '.' , './' , 'index.html' ) and not href . startswith (( '#' , 'mailto:' , 'tel:' , 'javascript:' )):
candidate = p . path
if candidate and not candidate . startswith ( '/' ):
candidate = '/' + candidate
if candidate and candidate not in route_set and candidate not in ( '/index.html' ,) and not ( dist / candidate . lstrip ( '/' )) . is_file () and candidate not in broken_links :
broken_links . append ( candidate )
for img in soup . select ( 'img[src]' ):
src = img . get ( 'src' , '' )
if src . startswith (( 'http://' , 'https://' )):
if src not in external : external . append ( src )
else :
rel = src . lstrip ( '/' )
if rel and not ( dist / rel ) . is_file () and rel not in missing : missing . append ( rel )
if external : warnings . append ( f ' { len ( external ) } 个外部图片 URL 尚未打包(可开启镜像重建)' )
if broken_links : warnings . append ( f ' { len ( broken_links ) } 个内部链接无对应页面或文件' )
if legacy_links : warnings . append ( f ' { len ( legacy_links ) } 个指向源站但未能映射的链接,需人工核验' )
if canonical_mismatches : warnings . append ( f ' { len ( canonical_mismatches ) } 个页面 canonical 域名与新站域名不一致' )
if missing : warnings . append ( f ' { len ( missing ) } 个本地引用图片缺失' )
if slashless : warnings . append ( f ' { len ( slashless ) } 个无后缀、无尾斜杠 URL 需要按照生成的 Nginx 规则部署' )
2026-10-23 12:31:03 +08:00
report = { 'site' : site [ 'name' ], 'locale' : locale , 'alternate_pages' : len ( language_alternates ), 'version' : bid , 'pages' : totals , 'theme' : site [ 'theme' ], 'theme_version' : theme_pin [ 'version' ] if theme_pin else theme_store . latest ( site [ 'theme' ]),
2026-10-22 16:49:25 +08:00
'warnings' : warnings , 'errors' : issues , 'external_assets' : external [: 200 ],
'missing_local_images' : missing [: 200 ], 'broken_links' : broken_links [: 200 ], 'legacy_links' : legacy_links [: 200 ], 'canonical_mismatches' : canonical_mismatches [: 200 ], 'slashless_routes' : slashless ,
'redirects' : len ( redirects ), 'mirrored_images' : len ( remote_cache ),
'quality_status' : 'warning' if warnings else 'passed' , 'blueprint_blocks' : sum ( len ( json . loads ( x . get ( 'meta_json' ) or ' {} ' ) . get ( 'blocks' ,[])) for x in local_records )}
# Separate static quality from readiness for commercial hand-off.
from . import commercial_readiness
handoff = commercial_readiness . inspect ( dist )
report [ 'commercial_readiness' ] = { 'ready_for_review' : handoff [ 'ready_for_review' ],
'commercially_certified' : False , 'blockers' : len ( handoff [ 'blockers' ]),
'warnings' : len ( handoff [ 'warnings' ])}
( dist / 'commercial-readiness.json' ) . write_text ( json . dumps ( handoff , ensure_ascii = False , indent = 2 ), encoding = 'utf-8' )
( dist / 'siteforge-report.json' ) . write_text ( json . dumps ( report , ensure_ascii = False , indent = 2 ), encoding = 'utf-8' )
archive_dir = db . DATA / 'archives' ; archive_dir . mkdir ( parents = True , exist_ok = True )
archive = archive_dir / ( site_id + '-' + bid + '.zip' )
with zipfile . ZipFile ( archive , 'w' , compression = zipfile . ZIP_DEFLATED , compresslevel = 6 ) as z :
for file in dist . rglob ( '*' ):
if file . is_file (): z . write ( file , file . relative_to ( dist ))
db . insert ( 'INSERT INTO builds (id,site_id,created_at,status,total_pages,report_json,zip_path) VALUES (?,?,?,?,?,?,?)' ,
( bid , site_id , db . now (), 'complete' , totals , json . dumps ( report , ensure_ascii = False ), str ( archive )))
return report