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:
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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。')
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user