chore: baseline V8.10.0-dev.1 with v8_workflows type-mapping fix and trade-pro spec

- SiteForge Enterprise V8.10.0-dev.1 upstream source (210 tests)
- Fix: v8_workflows._mapped_input now reads both 类型/分类 columns so WXR
  imports keep post/product types instead of collapsing to page
- Docs: trade-pro theme port spec (R3) and implementation plan

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-22 16:49:25 +08:00
commit 8cea7dee20
354 changed files with 20736 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
$ErrorActionPreference = 'Stop'
Set-Location (Join-Path $PSScriptRoot '..')
python -m compileall -q app
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
python -m pytest -q
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Get-ChildItem app/static -Filter *.js | ForEach-Object {
node --check $_.FullName
if ($LASTEXITCODE -ne 0) { throw "JavaScript syntax error: $_" }
}
if (Get-Command docker -ErrorAction SilentlyContinue) {
docker compose config --quiet
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
docker compose -f compose.enterprise.yaml config --quiet
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
python -m compileall -q app
python -m pytest -q
if command -v node >/dev/null 2>&1; then
for f in app/static/*.js; do node --check "$f"; done
else
echo "ERROR: Node.js required for JavaScript syntax check" >&2
exit 1
fi
if command -v docker >/dev/null 2>&1; then
docker compose config --quiet
docker compose -f compose.enterprise.yaml config --quiet
fi
+23
View File
@@ -0,0 +1,23 @@
"""Generate a local .env using only Docker's Python image (no host Python needed)."""
import os
import secrets
from pathlib import Path
root=Path(__file__).resolve().parent.parent
config=root/'.env'
if config.exists():
print('已存在 .env:为了防止覆盖现有密码和密钥,本次不做修改。')
raise SystemExit(0)
password=secrets.token_urlsafe(24)
secret=secrets.token_urlsafe(48)
pg_password=secrets.token_urlsafe(30)
deploy_secret=secrets.token_urlsafe(48)
config.write_text(f'''# SiteForge Factory 8.10.0-dev.1 - 不要上传至代码仓库\nSITEFORGE_PORT=8787\nSITEFORGE_ADMIN_PASSWORD={password}\nSITEFORGE_SESSION_SECRET={secret}\nSITEFORGE_COOKIE_SECURE=false\nSITEFORGE_DOMAIN=siteforge.example.com\nSITEFORGE_PG_USER=siteforge\nSITEFORGE_PG_PASSWORD={pg_password}\nSITEFORGE_PG_DB=siteforge\nSITEFORGE_DEPLOY_SECRET={deploy_secret}\n# Remote deployment target: configure HTTPS URLs and corresponding secrets manually.\nSITEFORGE_DEPLOY_STAGING_URL=\nSITEFORGE_DEPLOY_STAGING_SECRET=\nSITEFORGE_DEPLOY_PRODUCTION_URL=\nSITEFORGE_DEPLOY_PRODUCTION_SECRET=\n''', encoding='utf-8')
try:os.chmod(config,0o600)
except OSError:pass
print('SiteForge 已生成 .env 配置。')
print('首次登录管理员密码:'+password)
print('请将密码保存在安全的位置,忘记后可编辑 .env 的密码并重启容器。')
print('SQLite 单机启动:docker compose up -d --build')
print('PostgreSQL / Redis 企业测试:docker compose -f compose.enterprise.yaml up -d --build')
print('发布 Agent 应安装在独立机器,经 HTTPS 反向代理并限制访问 IP。')
+52
View File
@@ -0,0 +1,52 @@
"""Offline installer for SiteForge components into a user-provided official Art Design Pro checkout.
Run with --dry-run first. It does NOT download, modify auth schemes, or
pretend to have compiled the official repository in this sandbox.
"""
import argparse,shutil,re
from pathlib import Path
SOURCE=Path(__file__).resolve().parents[1]/'integrations'/'art-design-pro'/'src'/'views'/'siteforge'
MARKER='// SITEFORGE_FACTORY_V7_REGISTERED'
V8_MARKER='// SITEFORGE_FACTORY_V8_REGISTERED'
V8_ROUTES="""
// SITEFORGE_FACTORY_V8_REGISTERED
{ path:'/siteforge/production', name:'SiteForgeV8Production', component:'/siteforge/V8Production',
meta:{title:'V8 生产控制台',icon:'ri:factory-line'} },
"""
ROUTES='''\n // SITEFORGE_FACTORY_V7_REGISTERED\n {\n path: '/siteforge', name: 'SiteForge', component: '/index/index',\n meta: {title:'网站工厂',icon:'ri:layout-grid-line'},\n children: [\n {path:'factory',name:'SiteForgeFactory',component:'/siteforge/WebsiteFactory',meta:{title:'网站项目'}},\n {path:'themes',name:'SiteForgeThemes',component:'/siteforge/ThemeCenter',meta:{title:'主题与版本'}},\n {path:'enterprise',name:'SiteForgeEnterprise',component:'/siteforge/EnterpriseCenter',meta:{title:'企业控制'}},
{path:'editor',name:'SiteForgeEditor',component:'/siteforge/VisualEditor',meta:{title:'可视化编辑'}},
{path:'migration',name:'SiteForgeMigration',component:'/siteforge/MigrationCenter',meta:{title:'CMS 迁移'}},
{path:'releases',name:'SiteForgeReleases',component:'/siteforge/ReleaseCenter',meta:{title:'审批与发布'}},\n ]\n },\n'''
def install(root:Path,dry_run:bool):
base=root/'src'/'router'/'routes'/'asyncRoutes.ts'
target=root/'src'/'views'/'siteforge'
if not base.is_file() or not (root/'package.json').is_file():raise ValueError('不是受支持的 Art Design Pro 源码目录')
original=base.read_text(encoding='utf-8')
pat=r'(export\s+const\s+asyncRoutes\s*:[^=]*=\s*\[)'
if MARKER not in original:
modified,count=re.subn(pat,lambda m:m.group(1)+ROUTES,original,count=1)
if count!=1:raise ValueError('无法定位 asyncRoutes 定义;该官方版本的路由结构可能不同,请人工集成')
else:modified=original
if V8_MARKER not in modified:
modified,count=re.subn(pat,lambda m:m.group(1)+V8_ROUTES,modified,count=1)
if count!=1:raise ValueError('无法注册 V8 业务页面')
print(f'官方工程: {root}\n文件目标: {target}\n待复制页面: {len(list(SOURCE.glob("*.vue")))}\n路由需要更新: {original!=modified}')
if dry_run:return
target.mkdir(parents=True,exist_ok=True)
for src in SOURCE.glob('*.vue'):
dest=target/src.name
if dest.exists():shutil.copy2(dest,dest.with_suffix('.vue.siteforge-backup'))
shutil.copy2(src,dest)
if modified!=original:
shutil.copy2(base,base.with_suffix('.ts.siteforge-backup'))
base.write_text(modified,encoding='utf-8')
print('业务页面与菜单已写入;下一步请配置 /api、/editor、/preview 代理并执行 pnpm build 完成验收。')
if __name__=='__main__':
parser=argparse.ArgumentParser()
parser.add_argument('official_repo',type=Path)
parser.add_argument('--apply',action='store_true',help='执行源代码变更;不传则只检查')
args=parser.parse_args()
install(args.official_repo.resolve(),dry_run=not args.apply)
+82
View File
@@ -0,0 +1,82 @@
"""Build V8.7 four full theme demonstration sites from the real builder.
All sample names and content here are clearly fictitious demonstration data.
"""
import json, os, shutil
from pathlib import Path
BASE=Path(__file__).resolve().parent.parent
DATA=Path(os.environ.get('SITEFORGE_DATA_DIR',str(BASE/'demo_storage')))
os.environ['SITEFORGE_DATA_DIR']=str(DATA)
from app import db,theme_store,builder,quality,site_design_v87
from app.core import uid,save_entry
from app.factory_api import bootstrap_site
def put(sid,typ,title,route,summary='',body='',category='',brand='',extra=None):
fields={'category':category,'brand':brand}
if extra:fields.update(extra)
return save_entry(sid,{'type':typ,'title':title,'route':route,'summary':summary,'body':body,
'meta':{'fields':fields}},source='demo')
def build_demo():
db.init();theme_store.setup()
demos={
'automotive':('NOVA MOBILITY','探索更广阔的出行可能','汽车与出行演示主题','Cars'),
'industrial':('ARC INDUSTRIES','面向现代工程的产品与服务','工业制造演示主题','Materials'),
'corporate':('NORTHLINE GROUP','以专业的服务连接企业与未来','企业品牌演示主题','Services'),
'minimal':('MONO STUDIO','少而精,让每一项作品更清晰','极简设计演示主题','Objects'),
}
out=Path(os.environ.get('SITEFORGE_DEMO_OUT',str(BASE/'demo_sites')));out.mkdir(parents=True,exist_ok=True)
results={}
for theme,(name,tagline,about,term) in demos.items():
sid=uid()
settings={'nav':[{'title':x,'route':r} for x,r in [('首页','/'),('产品','/products/'),('品牌','/brands/'),('新闻','/news/'),('案例','/cases/'),('关于','/about/'),('联系','/contact/')]],
'email':'hello@example.com','footer_text':'此为 SiteForge 主题演示网站。公司、产品及内容均为虚构示例。'}
settings['site_design']=site_design_v87.validate({
'header':{'show_announcement':True,'announcement':'SITEFORGE V8.7 · 全站设计演示',
'show_cta':True,'cta_text':'开始合作','cta_href':'/contact/'},
'footer':{'title':name+' · 品牌展示','description':'SiteForge V8.7 全站页脚演示;本站所有业务信息均为虚构示例。',
'email':'demo@example.test','show_nav':True},
'nav':settings['nav'],
'responsive':{'tablet_gap':68,'mobile_gap':42,'mobile_container_padding':18,
'mobile_hide_announcement':True,'mobile_hide_footer_links':False},
'tokens':{'font':'system','heading_scale':1.0,'radius':12,'section_spacing':72,
'container_width':1200,'button_style':'rounded','header_style':'standard'},
})
db.insert('INSERT INTO sites VALUES (?,?,?,?,?,?,?,?,?,?,?)',
(sid,name,'https://'+theme+'.example.test',tagline,theme,'#315f92','', 'new',json.dumps(settings,ensure_ascii=False),db.now(),db.now()))
theme_store.pin(sid,theme,'1.1.0')
put(sid,'page','首页','/',tagline)
for i,(route,title,description) in enumerate([
('/about/','关于我们','企业介绍、发展理念、合作能力与团队故事。'),
('/contact/','联系我们','欢迎通过邮件与我们的演示团队取得联系。'),
('/products/','产品中心','查看系列、类别及产品资料。'),
('/brands/','品牌中心','探索与产品相关的品牌。'),
('/news/','新闻资讯','了解最新消息与团队动态。'),
('/cases/','项目案例','精选项目展示。'),
('/services/','服务与解决方案','从咨询到交付的全链路服务。'),
('/faq/','常见问题','更多问题可以联系我们。')]):
put(sid,'page',title,route,description,'<p>本页为演示内容。您可以在网站工厂中直接修改和替换。</p>')
for i,(prod,cat,brand) in enumerate([
(f'{term} Alpha','旗舰系列','NOVA'),(f'{term} Beta','旗舰系列','NOVA'),
(f'{term} Gamma','畅销系列','ARC'),(f'{term} Delta','畅销系列','ARC'),
(f'{term} Epsilon','定制系列','MONO'),(f'{term} Zeta','定制系列','MONO'),
(f'{term} Eta','专业系列','NOVA'),(f'{term} Theta','专业系列','ARC')]):
put(sid,'product',prod,f'/products/{i+1}/',f'{prod} — 针对多样应用的演示产品。',
'<h2>产品说明</h2><p>此处可以展示产品特点、应用场景和可用规格。</p>',cat,brand,
{'model':'MX-'+str(200+i),'material':'Example / Demo','version':'2026','energy':'EV' if theme=='automotive' else 'Standard'})
for name_b in ('NOVA','ARC','MONO'):
put(sid,'brand',name_b,'/brands/'+name_b.lower()+'/',f'{name_b} 品牌产品与应用。')
for i in range(3):
put(sid,'post',f'企业动态 · {i+1:02d}',f'/news/{i+1}/','在这里发布新闻资讯与品牌动态。',
'<p>这是可替换的新闻正文内容,可包含图片、表格和链接。</p>',extra={'publish_date':f'2026-10-{11+i:02}'})
put(sid,'case',f'精选项目 · {i+1:02d}',f'/cases/{i+1}/','展示项目背景、过程与成果。',
'<p>本页仅为项目结构示例,并非实际完成的工程项目。</p>',extra={'location':'Demo location','industry':'Demo industry'})
put(sid,'service','供应与交付方案','/services/solution/','灵活、可配置的供应交付服务。')
r=builder.build(sid)
src=db.DATA/'builds'/sid/r['version']/'dist'
dst=out/theme
if dst.exists():shutil.rmtree(dst)
shutil.copytree(src,dst)
results[theme]={'page_count':r['pages'],'warnings':r['warnings'],'quality':quality.report(sid,r['version'])}
(out/'BUILD_REPORT.json').write_text(json.dumps(results,ensure_ascii=False,indent=2),encoding='utf-8')
print(json.dumps({k:{'pages':v['page_count'],'warnings':len(v['warnings']),'quality_errors':v['quality']['errors']} for k,v in results.items()},ensure_ascii=False,indent=2))
if __name__=='__main__':build_demo()
+30
View File
@@ -0,0 +1,30 @@
"""Self-contained HTML preview from the actual four built static sites.
It inlines production CSS; navigation/build/SEO remains in the accompanying ZIP.
"""
import json,re,base64
from pathlib import Path
root=Path(__import__('sys').argv[1]).resolve() if len(__import__('sys').argv)>1 else Path(__file__).resolve().parent.parent/'demo_sites'
out=Path(__import__('sys').argv[2]).resolve() if len(__import__('sys').argv)>2 else Path(__file__).resolve().parent.parent/'theme-preview.html'
styles=['theme.css','blocks.css','factory.css','pro-base.css','pro.css','commercial.css','custom.css']
data={}
for theme in ('automotive','industrial','corporate','minimal'):
base=root/theme
pages={'home':'index.html','products':'products/index.html','detail':'products/1/index.html','news':'news/index.html','case':'cases/1/index.html','about':'about/index.html','contact':'contact/index.html'}
data[theme]={}
for name,file in pages.items():
src=(base/file).read_text(encoding='utf-8')
for css in styles:
text=(base/'assets'/css).read_text(encoding='utf-8')
visual=base/'assets'/'visual'/'hero.svg'
if visual.is_file():
text=text.replace('./visual/hero.svg','data:image/svg+xml;base64,'+base64.b64encode(visual.read_bytes()).decode())
src=src.replace(f'<link rel="stylesheet" href="assets/{css}">','<style>'+text+'</style>')
src=src.replace('<base href="/">','<base href="about:srcdoc">')
src=re.sub(r'<script\s+defer\s+src="assets/[^\"]+"></script>','',src)
data[theme][name]=src
payload=json.dumps(data,ensure_ascii=False).replace('</','<\\/')
html='''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge V7 · 四套商业视觉主题 · 真实构建预览</title><style>body{margin:0;background:#edf1f5;font:14px system-ui,-apple-system,'Microsoft YaHei',sans-serif;color:#1d2d41}header{background:#fff;border-bottom:1px solid #dfe4ea;padding:18px 24px;display:flex;gap:14px;align-items:center;justify-content:space-between}header strong{font-size:18px}header span{color:#72869b;font-size:12px}main{max-width:1550px;margin:auto;padding:20px 22px}.tools{display:flex;flex-wrap:wrap;gap:12px;align-items:end;margin-bottom:18px}.tools label{display:grid;gap:6px;font-size:11px;font-weight:bold;color:#6a7c8c}.tools select{padding:10px 13px;border:1px solid #d0dae2;border-radius:7px;background:#fff;min-width:135px;font-size:13px;color:#1b2d40}.devices{margin-left:auto;display:flex;background:#fff;border:1px solid #d0dae2;border-radius:7px;padding:3px}.devices button{cursor:pointer;border:0;background:none;color:#63788c;padding:9px 15px}.devices button.active{background:#183b60;color:#fff;border-radius:5px}.stage{display:flex;justify-content:center}.frame{width:100%;height:760px;background:white;box-shadow:0 18px 40px #253c4b25;transition:width .2s;border:1px solid #d5dee5;overflow:hidden;border-radius:8px}.frame iframe{width:100%;height:100%;border:0}p.note{color:#738697;line-height:1.7;font-size:12px;text-align:center;margin-top:15px}@media(max-width:680px){.devices{margin-left:0}header{display:block}header span{display:block;margin-top:8px}main{padding:12px}}</style></head>
<body><header><strong>SiteForge V7 · 专业主题效果预览</strong><span>直接打开本文件 · 不需要 Docker · 展示真实构建的 HTML/CSS</span></header><main><div class="tools"><label>选择主题<select id="theme"><option value="automotive">汽车 Automotive</option><option value="industrial">工业 Industrial</option><option value="corporate">企业 Corporate</option><option value="minimal">极简 Minimal</option></select></label><label>查看页面<select id="page"><option value="home">首页</option><option value="products">产品列表</option><option value="detail">产品详情</option><option value="news">新闻资讯</option><option value="case">案例详情</option><option value="about">关于我们</option><option value="contact">联系我们</option></select></label><div class="devices"><button class="active" data-width="100%">PC</button><button data-width="820px">平板</button><button data-width="390px">手机</button></div></div><div class="stage"><div class="frame" id="frame"><iframe id="preview" title="实际静态网站预览" sandbox=""></iframe></div></div><p class="note">演示包含虚构企业与产品数据。此单文件用于查看实际生成页面的视觉布局;完整网站的导航、搜索和筛选请在 Docker 或静态 Web 服务器部署演示站后体验。</p></main>
<script id="data" type="application/json">__PAYLOAD__</script><script>const data=JSON.parse(document.getElementById('data').textContent),theme=document.getElementById('theme'),page=document.getElementById('page'),iframe=document.getElementById('preview');function render(){iframe.srcdoc=data[theme.value][page.value]}theme.onchange=render;page.onchange=render;document.querySelectorAll('[data-width]').forEach(button=>button.onclick=()=>{document.querySelectorAll('[data-width]').forEach(b=>b.classList.remove('active'));button.classList.add('active');document.getElementById('frame').style.width=button.dataset.width;});render();</script></body></html>'''.replace('__PAYLOAD__',payload)
out.write_text(html,encoding='utf-8')
print(str(out),out.stat().st_size,'bytes')
+71
View File
@@ -0,0 +1,71 @@
"""Build four fully static demonstration sites from the real V3 builder.
All sample names and content here are clearly fictitious demonstration data.
"""
import json, os, shutil
from pathlib import Path
BASE=Path(__file__).resolve().parent.parent
DATA=Path(os.environ.get('SITEFORGE_DATA_DIR',str(BASE/'demo_storage')))
os.environ['SITEFORGE_DATA_DIR']=str(DATA)
from app import db,theme_store,builder,quality
from app.core import uid,save_entry
from app.factory_api import bootstrap_site
def put(sid,typ,title,route,summary='',body='',category='',brand='',extra=None):
fields={'category':category,'brand':brand}
if extra:fields.update(extra)
return save_entry(sid,{'type':typ,'title':title,'route':route,'summary':summary,'body':body,
'meta':{'fields':fields}},source='demo')
def build_demo():
db.init();theme_store.setup()
demos={
'automotive':('NOVA MOBILITY','探索更广阔的出行可能','汽车与出行演示主题','Cars'),
'industrial':('ARC INDUSTRIES','面向现代工程的产品与服务','工业制造演示主题','Materials'),
'corporate':('NORTHLINE GROUP','以专业的服务连接企业与未来','企业品牌演示主题','Services'),
'minimal':('MONO STUDIO','少而精,让每一项作品更清晰','极简设计演示主题','Objects'),
}
out=Path(os.environ.get('SITEFORGE_DEMO_OUT',str(BASE/'demo_sites')));out.mkdir(parents=True,exist_ok=True)
results={}
for theme,(name,tagline,about,term) in demos.items():
sid=uid()
settings={'nav':[{'title':x,'route':r} for x,r in [('首页','/'),('产品','/products/'),('品牌','/brands/'),('新闻','/news/'),('案例','/cases/'),('关于','/about/'),('联系','/contact/')]],
'email':'hello@example.com','footer_text':'此为 SiteForge 主题演示网站。公司、产品及内容均为虚构示例。'}
db.insert('INSERT INTO sites VALUES (?,?,?,?,?,?,?,?,?,?,?)',
(sid,name,'https://'+theme+'.example.test',tagline,theme,'#315f92','', 'new',json.dumps(settings,ensure_ascii=False),db.now(),db.now()))
theme_store.pin(sid,theme,'1.1.0')
put(sid,'page','首页','/',tagline)
for i,(route,title,description) in enumerate([
('/about/','关于我们','企业介绍、发展理念、合作能力与团队故事。'),
('/contact/','联系我们','欢迎通过邮件与我们的演示团队取得联系。'),
('/products/','产品中心','查看系列、类别及产品资料。'),
('/brands/','品牌中心','探索与产品相关的品牌。'),
('/news/','新闻资讯','了解最新消息与团队动态。'),
('/cases/','项目案例','精选项目展示。'),
('/services/','服务与解决方案','从咨询到交付的全链路服务。'),
('/faq/','常见问题','更多问题可以联系我们。')]):
put(sid,'page',title,route,description,'<p>本页为演示内容。您可以在网站工厂中直接修改和替换。</p>')
for i,(prod,cat,brand) in enumerate([
(f'{term} Alpha','旗舰系列','NOVA'),(f'{term} Beta','旗舰系列','NOVA'),
(f'{term} Gamma','畅销系列','ARC'),(f'{term} Delta','畅销系列','ARC'),
(f'{term} Epsilon','定制系列','MONO'),(f'{term} Zeta','定制系列','MONO'),
(f'{term} Eta','专业系列','NOVA'),(f'{term} Theta','专业系列','ARC')]):
put(sid,'product',prod,f'/products/{i+1}/',f'{prod} — 针对多样应用的演示产品。',
'<h2>产品说明</h2><p>此处可以展示产品特点、应用场景和可用规格。</p>',cat,brand,
{'model':'MX-'+str(200+i),'material':'Example / Demo','version':'2026','energy':'EV' if theme=='automotive' else 'Standard'})
for name_b in ('NOVA','ARC','MONO'):
put(sid,'brand',name_b,'/brands/'+name_b.lower()+'/',f'{name_b} 品牌产品与应用。')
for i in range(3):
put(sid,'post',f'企业动态 · {i+1:02d}',f'/news/{i+1}/','在这里发布新闻资讯与品牌动态。',
'<p>这是可替换的新闻正文内容,可包含图片、表格和链接。</p>',extra={'publish_date':f'2026-10-{11+i:02}'})
put(sid,'case',f'精选项目 · {i+1:02d}',f'/cases/{i+1}/','展示项目背景、过程与成果。',
'<p>本页仅为项目结构示例,并非实际完成的工程项目。</p>',extra={'location':'Demo location','industry':'Demo industry'})
put(sid,'service','供应与交付方案','/services/solution/','灵活、可配置的供应交付服务。')
r=builder.build(sid)
src=db.DATA/'builds'/sid/r['version']/'dist'
dst=out/theme
if dst.exists():shutil.rmtree(dst)
shutil.copytree(src,dst)
results[theme]={'page_count':r['pages'],'warnings':r['warnings'],'quality':quality.report(sid,r['version'])}
(out/'BUILD_REPORT.json').write_text(json.dumps(results,ensure_ascii=False,indent=2),encoding='utf-8')
print(json.dumps({k:{'pages':v['page_count'],'warnings':len(v['warnings']),'quality_errors':v['quality']['errors']} for k,v in results.items()},ensure_ascii=False,indent=2))
if __name__=='__main__':build_demo()
+73
View File
@@ -0,0 +1,73 @@
"""Deterministic P3 5,000-product acceptance pilot (isolated, no network).
Run: python scripts/pilot_5000.py --report pilot.json
Optional full build: python scripts/pilot_5000.py --build --report pilot-build.json
No production data are opened or modified. Uses an independent temporary SQLite
volume, 10 x 500 rows; builds the full automotive static site and runs static QA.
A successful test is NOT a commercial content/SEO readiness certificate.
"""
import argparse
import json
import os
import sys
import tempfile
import time
import subprocess
from pathlib import Path
ROOT=Path(__file__).resolve().parent.parent
sys.path.insert(0,str(ROOT))
def run(build=False):
started=time.monotonic()
with tempfile.TemporaryDirectory(prefix='siteforge-5k-pilot-') as work:
os.environ['SITEFORGE_DATA_DIR']=work
os.environ.setdefault('SITEFORGE_ADMIN_PASSWORD','offline-pilot-only-long-password-123456789')
os.environ.setdefault('SITEFORGE_SESSION_SECRET','offline-pilot-only-session-1234567890123456789')
from fastapi.testclient import TestClient
from app.main import app
from app import db,builder,quality,v8_workflows as wf
from app.core import uid
with TestClient(app):
sid=uid();now=db.now()
db.insert('INSERT INTO sites(id,name,theme,created_at,updated_at) VALUES(?,?,?,?,?)',
(sid,'5K Acceptance Pilot','automotive',now,now))
for offset in range(0,5000,500):
data=[{'车型名称':f'Vehicle {i:05d}','品牌':f'Brand {i%20:02d}',
'SEO描述':f'Demo vehicle {i:05d}, pilot only'}
for i in range(offset,offset+500)]
batch=wf.stage(sid,data,kind='vehicle',origin=f'pilot-{offset//500}',
source_keys=[str(i) for i in range(offset,offset+500)])
result=wf.commit(sid,batch['batch_id'])
assert result['created']==500 and not result['errors'],result
import_sec=round(time.monotonic()-started,2)
assert db.one("SELECT COUNT(*) n FROM entries WHERE site_id=? AND type='vehicle'",(sid,))['n']==5000
if not build:
return {'status':'import_passed_build_not_run','source_rows':5000,
'committed':5000,'batches':10,'import_seconds':import_sec,
'note':'Use --build in a sufficiently provisioned CI runner for full static-build QA.'}
report=builder.build(sid,False)
build_sec=round(time.monotonic()-started-import_sec,2)
# Release/SEO QA runs in its own short-lived process in production.
# Do not reuse the large catalogue renderer's heap for the audit.
audit_code=("import json,sys; from app import quality; "
"print(json.dumps(quality.report(sys.argv[1],sys.argv[2])))")
audit=subprocess.run([sys.executable,'-c',audit_code,sid,report['version']],
cwd=str(ROOT),text=True,capture_output=True,timeout=120,check=True,
env=dict(os.environ))
qa=json.loads(audit.stdout)
result={'status':'passed' if qa['errors']==0 and report['pages']>=5000 else 'failed',
'source_rows':5000,'batches':10,'pages':report['pages'],'html_files':qa['files'],
'static_errors':qa['errors'],'static_warnings':qa['warnings'],
'import_seconds':import_sec,'build_seconds':build_sec,
'total_seconds':round(time.monotonic()-started,2),
'disclaimer':'Fixed sample data; not live MySQL, not Docker, not production SEO audit.'}
if result['status']!='passed':raise AssertionError(result)
return result
if __name__=='__main__':
p=argparse.ArgumentParser();p.add_argument('--report');p.add_argument('--build',action='store_true');args=p.parse_args()
result=run(build=args.build)
if args.report:Path(args.report).write_text(json.dumps(result,ensure_ascii=False,indent=2))
print(json.dumps(result,ensure_ascii=False,indent=2))
+91
View File
@@ -0,0 +1,91 @@
"""Offline, verified, atomic(ish) restore for single-volume SQLite SiteForge.
Stop Web and Workers. Destination is replaced as one directory rename; previous
contents are retained in a sibling .pre-restore folder for manual rollback.
On failure the old destination stays unchanged. Use only on the same filesystem.
"""
import argparse
import os
import sqlite3
import stat
import tempfile
import zipfile
from datetime import datetime, timezone
from pathlib import Path
ALLOWED_ROOTS={'media','legacy','builds','archives'}
MAX_FILES=250000
MAX_UNCOMPRESSED=30_000_000_000
def _inspect_zip(z):
infos=z.infolist()
if len(infos)>MAX_FILES:raise ValueError('备份文件数量过大')
if sum(i.file_size for i in infos)>MAX_UNCOMPRESSED:raise ValueError('解压数据总量过大')
names=set()
for info in infos:
p=Path(info.filename)
if (p.is_absolute() or not p.parts or any(part in ('..','.') for part in p.parts)
or '\\' in info.filename or ':' in info.filename or '\x00' in info.filename):
raise ValueError('备份包含不安全路径')
if stat.S_ISLNK(info.external_attr>>16):raise ValueError('不允许符号链接')
if p.parts[0]!='siteforge.sqlite3' and p.parts[0] not in ALLOWED_ROOTS:
raise ValueError('备份包含未知文件范围: '+str(p)[:100])
if p.parts[0]=='siteforge.sqlite3' and len(p.parts)!=1:
raise ValueError('数据库名称不合法')
if not info.is_dir() and info.filename in names:raise ValueError('备份包含重复路径')
names.add(info.filename)
if info.file_size>0 and info.compress_size and info.file_size>5_000_000 and info.file_size/info.compress_size>1000:
raise ValueError('异常压缩比,拒绝解压')
if 'siteforge.sqlite3' not in names:raise ValueError('缺少数据库文件')
return infos
def restore(path,target,confirmed=False):
if not confirmed:raise RuntimeError('停止 API 与 Worker 后,使用 --confirmed-offline 才能恢复')
target=Path(target).resolve();archive=Path(path).resolve()
if not archive.is_file():raise FileNotFoundError(archive)
target.parent.mkdir(parents=True,exist_ok=True)
# Preflight the entire archive before changing the previous installation.
with zipfile.ZipFile(archive) as z:
infos=_inspect_zip(z)
with tempfile.TemporaryDirectory(prefix='.siteforge-restore-',dir=target.parent) as tmp:
stage=Path(tmp)/'new';stage.mkdir()
# Never follow symlinks out of the staging directory.
for item in infos:
dest=stage/item.filename
if item.is_dir():dest.mkdir(parents=True,exist_ok=True);continue
dest.parent.mkdir(parents=True,exist_ok=True)
with z.open(item) as src, dest.open('wb') as out:
import shutil
shutil.copyfileobj(src,out,1024*1024)
with sqlite3.connect(stage/'siteforge.sqlite3') as conn:
if conn.execute('PRAGMA integrity_check').fetchone()[0]!='ok':
raise ValueError('数据库完整性校验失败')
if not conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='sites'").fetchone():
raise ValueError('数据库不包含 SiteForge sites 表')
previous=None
if target.exists():
previous=target.with_name(target.name+'.pre-restore-'+datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S'))
suffix=0
while previous.exists():
suffix+=1
previous=target.with_name(target.name+'.pre-restore-'+datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')+'-'+str(suffix))
target.rename(previous)
try:
stage.rename(target)
except BaseException:
if previous is not None and previous.exists() and not target.exists():previous.rename(target)
raise
print('恢复完成: '+str(target))
if previous:print('旧数据保留在: '+str(previous))
return {'target':str(target),'previous':str(previous) if previous else None}
if __name__=='__main__':
parser=argparse.ArgumentParser()
parser.add_argument('archive')
parser.add_argument('--data',default=os.getenv('SITEFORGE_DATA_DIR','/data'))
parser.add_argument('--confirmed-offline',action='store_true')
args=parser.parse_args()
restore(args.archive,args.data,args.confirmed_offline)
+24
View File
@@ -0,0 +1,24 @@
"""One-time developer script: break V3 full themes into independently maintainable Jinja page templates."""
from pathlib import Path
import re
base=Path(__file__).resolve().parent.parent/'themes'
types={'home':['home'],'search':['search'],'listing':['listing','category'],'product':['product'],
'brand':['brand'],'article':['article'],'case':['case'],'info':['about','service','contact','faq','standard'],'notfound':['notfound']}
markers=[("{% if kind=='home' %}",'home'),("{% elif kind=='search' %}",'search'),("{% elif kind in ['listing','category'] %}",'listing'),("{% elif kind=='product' %}",'product'),("{% elif kind=='brand' %}",'brand'),("{% elif kind=='article' %}",'article'),("{% elif kind=='case' %}",'case'),("{% elif kind in ('about','service','contact','faq','standard') %}",'info'),("{% elif kind=='notfound' %}",'notfound')]
for name in ('automotive','industrial','corporate','minimal'):
p=base/name/'pro.html'
t=p.read_text(encoding='utf-8')
beginning=t.index(markers[0][0]); ending=t.index('{% endif %}\n{% if has_custom',beginning)
common=t[:beginning]
remainder=t[ending+len('{% endif %}'):] # preserve custom blocks and footer
dir=base/name/'pages';dir.mkdir(parents=True,exist_ok=True)
pieces={}
for i,(marker,key) in enumerate(markers):
start=t.index(marker,beginning)+len(marker)
finish=t.index(markers[i+1][0],start) if i+1<len(markers) else ending
pieces[key]=t[start:finish].strip()+'\n'
for key,kinds in types.items():
for kind in kinds:
(dir/(kind+'.html')).write_text(pieces[key],encoding='utf-8')
p.write_text(common+"{% include '"+name+"/pages/' ~ kind ~ '.html' %}\n"+remainder,encoding='utf-8')
print(name,len(list(dir.glob('*.html'))),'page family files')
+20
View File
@@ -0,0 +1,20 @@
"""PostgreSQL bootstrap smoke: execute only in a throwaway database."""
import os
if not os.getenv('SITEFORGE_DATABASE_URL','').startswith(('postgresql://','postgres://')):
raise SystemExit('Please set SITEFORGE_DATABASE_URL to a disposable PostgreSQL database')
from app import db,enterprise,factory_api,migration_audit,reusables,release_api,jobs
from app.core import save_entry
import uuid
db.init();enterprise.setup();factory_api.setup();migration_audit.setup();reusables.setup();release_api.setup()
sid=uuid.uuid4().hex[:16]
with db.connect() as c:
c.execute('INSERT INTO sites(id,name,domain,created_at,updated_at) VALUES (?,?,?,?,?)',(sid,'PG Smoke','','2026-01-01','2026-01-01'))
c.execute('INSERT INTO entries(id,site_id,type,title,route,slug,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)',
(uuid.uuid4().hex[:16],sid,'page','Home','/','home','2026-01-01','2026-01-01'))
assert db.one('SELECT id FROM sites WHERE id=?',(sid,))['id']==sid
job=jobs.submit(sid,{'mirror_images':False},'pg-smoke','pg-smoke-unique-0001')
assert job['state']=='queued'
assert jobs.cancel(sid,job['id'])['state']=='cancelled'
with db.connect() as c:c.execute('DELETE FROM sites WHERE id=?',(sid,))
print('PostgreSQL schema, CRUD, job submit, cancel smoke passed')
+25
View File
@@ -0,0 +1,25 @@
"""Fast static checks before producing an official release archive."""
from pathlib import Path
import re
import subprocess
import sys
ROOT=Path(__file__).resolve().parent.parent
sys.path.insert(0,str(ROOT))
from app.version import VERSION
assert VERSION=='8.10.0-dev.1'
api=(ROOT/'app'/'main.py').read_text(encoding='utf-8')
assert 'return builder.build(' not in api, 'A synchronous request-thread build has reappeared'
assert "@app.post('/api/sites/{site_id}/builds', status_code=202)" in api
assert 'VERSION' in api
for name in ('automotive','industrial','corporate','minimal'):
theme=ROOT/'themes'/name
manifest=theme/'manifest.json'
assert manifest.is_file() and (theme/'pro.html').is_file()
assert len(list((theme/'pages').glob('*.html')))>=14
for file in (ROOT/'requirements.lock',ROOT/'requirements-dev.lock'):
pins=[ln for ln in file.read_text(encoding='utf-8').splitlines() if ln and not ln.startswith('#')]
assert pins and all(re.fullmatch(r'[a-zA-Z0-9_.-]+==[0-9][a-zA-Z0-9_.!+\-]*',ln) for ln in pins)
assert (ROOT/'.github/workflows/ci.yml').is_file()
for file in (ROOT/'app/static').glob('*.js'):
subprocess.run(['node','--check',str(file)],check=True)
print('PASS: version, 4× themes, worker route, pinned dependencies, CI workflow, JS syntax')