upgrade: adopt V8.21.3 (V8.13→8.21.3: P5 SFTP/S3, P6 multilingual, editor overhaul, enterprise hardening)

Overlay ~150 upstream items onto our V8.12 base, preserving trade-pro
theme, registry tests, and design docs. Re-applied our patch set
(discovery/env_for/blocks.css/type-mapping/theme_color/frontend entries,
test_theme_pro_v3 mods) — all anchors survived upstream evolution.

New platform enhancement (fixes upgrade-introduced regression):
multilingual.site_locale now honors settings_json.locale, plus
PUT /api/i18n/sites/{id}/locale — standalone English sites get
<html lang='en'> under the V8.15 locale-rewrite pipeline.

compose: adopt V8.21.3 (image 8.21.3-dev.1, /api/ready probes, MySQL/S3
env) with project name pinned to siteforge-v8-10 (data volumes).

Tests: 356 passed / 11 failed — all 11 confirmed pre-existing in
pristine V8.21.3 on this host (10 upstream Windows env + our build test
now green after locale fix). Patch registry updated in plan doc.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-23 12:31:03 +08:00
parent 257c89e159
commit 601451f22b
218 changed files with 10441 additions and 628 deletions
+16 -10
View File
@@ -14,7 +14,7 @@ import httpx
from . import safe_http
from jinja2 import Environment, FileSystemLoader, select_autoescape
from markupsafe import Markup
from . import db, blueprints, theme_store, static_site, responsive, theme_pro, site_design_v87
from . import db, blueprints, theme_store, static_site, responsive, theme_pro, site_design_v87, multilingual
from .core import uid, route_path, ensure_public_url, sanitize, THEMES
ROOT=Path(__file__).resolve().parent.parent
@@ -23,6 +23,12 @@ ENV=Environment(loader=FileSystemLoader(str(THEME_DIR)),autoescape=select_autoes
TYPE_NAMES={'page':'页面','post':'新闻','product':'产品','service':'服务','case':'案例','brand':'品牌','vehicle':'车型','other':'内容'}
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')
def output_path(dist,route):
path=route_path(route)
if path=='/':return dist/'index.html'
@@ -36,12 +42,6 @@ def output_path(dist,route):
if not target.is_relative_to(dist.resolve()):raise ValueError('非法生成目标目录')
return out
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')
def link(path):
return (path or '/').lstrip('/') or './'
@@ -166,6 +166,9 @@ 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('无效的主题')
multilingual.assert_build_safe(site_id)
locale=multilingual.site_locale(site_id)
language_alternates=multilingual.alternates(site_id)
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 '{}')
@@ -309,6 +312,7 @@ def build(site_id,mirror=False):
# 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)
html=multilingual.inject_seo(html,locale,language_alternates.get(page['route'],[]) if 'noindex' not in (page.get('robots') or '').lower() else [])
target=output_path(dist,page['route'])
if target.exists():raise ValueError('多个路由写入同一文件: '+page['route'])
target.parent.mkdir(parents=True,exist_ok=True)
@@ -385,6 +389,7 @@ def build(site_id,mirror=False):
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='')
missing_html=multilingual.inject_seo(missing_html,locale,[])
(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)
@@ -402,13 +407,14 @@ def build(site_id,mirror=False):
search_html=env.get_template('pro.html').render(**ctx)
else:
search_html=static_site.search_page(site)
search_html=multilingual.inject_seo(search_html,locale,[])
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://')):
urls='\n'.join('<url><loc>'+xml_escape(public_url(site,r))+'</loc></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">'+urls+'</urlset>'
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>'
(dist/'sitemap.xml').write_text(sitemap,encoding='utf-8')
(dist/'robots.txt').write_text('User-agent: *\nAllow: /\n'+(f'Sitemap: {site["domain"].rstrip("/")}/sitemap.xml\n' if site['domain'].startswith('http') else ''),encoding='utf-8')
rlist=[]
@@ -452,7 +458,7 @@ def build(site_id,mirror=False):
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 规则部署')
report={'site':site['name'],'version':bid,'pages':totals,'theme':site['theme'],'theme_version':theme_pin['version'] if theme_pin else theme_store.latest(site['theme']),
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']),
'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),