build_manager.py 48 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. 中央构建管理器
  5. 完全基于 .github/versions.json 动态生成分支名称和构建配置
  6. 支持 Git Worktree 隔离构建,避免分支切换问题
  7. """
  8. import os
  9. import sys
  10. import json
  11. import shutil
  12. import subprocess
  13. import argparse
  14. import platform
  15. import re
  16. from pathlib import Path
  17. from typing import List, Dict, Optional, Union
  18. from shutil import which
  19. import yaml
  20. from utils.i18n_config import I18nConfigManager
  21. class VersionConfig:
  22. """版本配置类"""
  23. def __init__(self, config_dict: Dict):
  24. self.name = config_dict['name']
  25. self.display_name = config_dict['display_name']
  26. self.branch = config_dict['branch']
  27. self.url_path = config_dict['url_path']
  28. self.description = config_dict.get('description', '')
  29. class BuildManager:
  30. """构建管理器"""
  31. def __init__(self):
  32. self.project_root = self._find_project_root()
  33. self.versions_file = self.project_root / '.github' / 'versions.json'
  34. self.docs_source = self.project_root / 'docs' / 'source'
  35. # 统一切换到新的构建输出根目录: source_build/html/<version>
  36. self.build_root = self.docs_source / 'source_build'
  37. self.worktrees_dir = self.build_root / 'worktrees'
  38. self.versions_dir = self.build_root / 'html'
  39. # 初始化国际化配置管理器
  40. config_path = self.docs_source / 'config.yaml'
  41. self.i18n_manager = I18nConfigManager(config_path)
  42. def _find_project_root(self) -> Path:
  43. """查找项目根目录"""
  44. current = Path.cwd()
  45. while current != current.parent:
  46. if (current / '.github' / 'versions.json').exists():
  47. return current
  48. current = current.parent
  49. raise FileNotFoundError("找不到 .github/versions.json 文件")
  50. def load_versions_config(self) -> Dict:
  51. """加载版本配置文件"""
  52. try:
  53. with open(self.versions_file, 'r', encoding='utf-8') as f:
  54. config = json.load(f)
  55. print(f"✓ 加载版本配置: {[v['name'] for v in config.get('versions', [])]}")
  56. return config
  57. except Exception as e:
  58. print(f"✗ 无法加载版本配置: {e}")
  59. return {'versions': [], 'default_version': '', 'latest_version': ''}
  60. def get_version_configs(self) -> List[VersionConfig]:
  61. """获取版本配置列表"""
  62. config = self.load_versions_config()
  63. versions = []
  64. for version_dict in config.get('versions', []):
  65. versions.append(VersionConfig(version_dict))
  66. return versions
  67. def create_worktree(self, version_config: VersionConfig) -> Path:
  68. """为指定版本创建 Git worktree"""
  69. worktree_path = self.worktrees_dir / version_config.name
  70. # 获取当前分支
  71. current_branch = subprocess.run(
  72. ['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
  73. capture_output=True, text=True, check=True
  74. ).stdout.strip()
  75. # 如果目标分支就是当前分支,直接使用当前目录
  76. if version_config.branch == current_branch:
  77. print(f"目标分支 {version_config.branch} 就是当前分支,使用当前目录")
  78. return Path.cwd()
  79. # 清理已存在的 worktree
  80. if worktree_path.exists():
  81. print(f"清理已存在的 worktree: {worktree_path}")
  82. try:
  83. subprocess.run(['git', 'worktree', 'remove', str(worktree_path)],
  84. check=True, capture_output=True)
  85. except subprocess.CalledProcessError:
  86. # 如果 worktree remove 失败,手动删除
  87. shutil.rmtree(worktree_path, ignore_errors=True)
  88. # 创建新的 worktree
  89. print(f"创建 worktree: {version_config.branch} -> {worktree_path}")
  90. subprocess.run([
  91. 'git', 'worktree', 'add',
  92. str(worktree_path), version_config.branch
  93. ], check=True)
  94. return worktree_path
  95. def build_docs_in_worktree(self, worktree_path: Path, version_config: VersionConfig) -> bool:
  96. """在 worktree 中构建文档"""
  97. print(f"在 worktree 中构建文档: {worktree_path}")
  98. # 检查 docs/source 目录是否存在
  99. if worktree_path == Path.cwd():
  100. # 如果是当前分支,使用主分支的 docs/source 目录
  101. docs_source_in_worktree = self.docs_source
  102. else:
  103. docs_source_in_worktree = worktree_path / 'docs' / 'source'
  104. if not docs_source_in_worktree.exists():
  105. print(f"⚠️ 警告: {worktree_path} 中没有 docs/source 目录")
  106. print(f" 使用主分支的文档结构进行构建")
  107. # 复制主分支的文档结构
  108. main_docs = self.docs_source
  109. if main_docs.exists():
  110. shutil.copytree(main_docs, docs_source_in_worktree, dirs_exist_ok=True)
  111. else:
  112. print(f"✗ 错误: 主分支也没有 docs/source 目录")
  113. return False
  114. # 切换到 worktree 目录(如果不是当前分支)
  115. if worktree_path != Path.cwd():
  116. os.chdir(worktree_path)
  117. try:
  118. # 读取项目名称用于 PDF 命名
  119. project_name = 'SDK_Docs'
  120. try:
  121. cfg_path = docs_source_in_worktree / 'config.yaml'
  122. if cfg_path.exists():
  123. with open(cfg_path, 'r', encoding='utf-8') as f:
  124. cfg = yaml.safe_load(f) or {}
  125. project_name = (cfg.get('project', {}) or {}).get('name', project_name)
  126. except Exception:
  127. pass
  128. def _slugify(name: str) -> str:
  129. safe = []
  130. for ch in name:
  131. if ch.isalnum() or ('\u4e00' <= ch <= '\u9fa5'):
  132. safe.append(ch)
  133. elif ch in [' ', '-', '_']:
  134. safe.append('_' if ch == ' ' else ch)
  135. s = ''.join(safe).strip('_')
  136. return s or 'SDK_Docs'
  137. pdf_basename = _slugify(project_name) + '.pdf'
  138. # 运行文档生成脚本(如果存在)
  139. doc_generator = docs_source_in_worktree / 'doc_generator.py'
  140. if doc_generator.exists():
  141. print(f"运行文档生成脚本: {doc_generator}")
  142. subprocess.run([sys.executable, str(doc_generator)],
  143. cwd=str(docs_source_in_worktree), check=True)
  144. # 嵌入版本配置
  145. embed_script = docs_source_in_worktree / 'utils' / 'embed_version_config.py'
  146. if embed_script.exists():
  147. print(f"嵌入版本配置: {embed_script}")
  148. subprocess.run([sys.executable, str(embed_script)],
  149. cwd=str(docs_source_in_worktree), check=True)
  150. # 构建 HTML 文档 - 使用国际化配置管理器
  151. output_dir = self.build_root / 'html' / version_config.url_path
  152. print(f"构建 HTML 文档: {output_dir}")
  153. # 构建中文版文档
  154. print("构建中文版文档...")
  155. zh_output_dir = output_dir / 'zh'
  156. zh_config = self.i18n_manager.get_language_config('zh')
  157. zh_env = os.environ.copy()
  158. zh_env['SPHINX_MASTER_DOC'] = zh_config['index_filename'].replace('.rst', '')
  159. zh_env['SPHINX_MASTER_DOC_OVERRIDE'] = zh_config['index_filename'].replace('.rst', '')
  160. zh_env['SPHINX_LANGUAGE'] = 'zh_CN'
  161. # 确保中文locale环境变量
  162. zh_env['LANG'] = 'zh_CN.UTF-8'
  163. zh_env['LC_ALL'] = 'zh_CN.UTF-8'
  164. zh_env['LC_CTYPE'] = 'zh_CN.UTF-8'
  165. # 中文版构建时临时移动英文版文件,避免Sphinx读取
  166. moved_files = []
  167. try:
  168. # 从配置文件读取分类列表
  169. cfg_path = docs_source_in_worktree / 'config.yaml'
  170. if cfg_path.exists():
  171. with open(cfg_path, 'r', encoding='utf-8') as f:
  172. cfg = yaml.safe_load(f) or {}
  173. categories = cfg.get('generation', {}).get('output_structure', [])
  174. for category in categories:
  175. # 临时移动英文版分类索引文件
  176. en_index_file = docs_source_in_worktree / category / 'index.rst'
  177. if en_index_file.exists():
  178. temp_file = en_index_file.with_suffix('.rst.temp')
  179. en_index_file.rename(temp_file)
  180. moved_files.append((en_index_file, temp_file))
  181. print(f" 临时移动英文版文件: {en_index_file} -> {temp_file}")
  182. # 临时移动英文版主索引文件
  183. en_main_index = docs_source_in_worktree / 'index.rst'
  184. if en_main_index.exists():
  185. temp_file = en_main_index.with_suffix('.rst.temp')
  186. en_main_index.rename(temp_file)
  187. moved_files.append((en_main_index, temp_file))
  188. print(f" 临时移动英文版文件: {en_main_index} -> {temp_file}")
  189. except Exception as e:
  190. print(f" 警告: 移动英文版文件时出错: {e}")
  191. # 中文版构建时排除英文文档
  192. zh_env['SPHINX_EXCLUDE_PATTERNS'] = '*.md'
  193. print(f"中文版构建环境变量:")
  194. print(f" LANG: {zh_env.get('LANG', 'N/A')}")
  195. print(f" LC_ALL: {zh_env.get('LC_ALL', 'N/A')}")
  196. print(f" SPHINX_LANGUAGE: {zh_env.get('SPHINX_LANGUAGE', 'N/A')}")
  197. print(f" SPHINX_MASTER_DOC: {zh_env.get('SPHINX_MASTER_DOC', 'N/A')}")
  198. print(f" SPHINX_EXCLUDE_PATTERNS: {zh_env.get('SPHINX_EXCLUDE_PATTERNS', 'N/A')}")
  199. print(f" 索引文件名: {zh_config['index_filename']}")
  200. subprocess.run([
  201. sys.executable, '-m', 'sphinx.cmd.build',
  202. '-b', 'html',
  203. '-D', 'language=zh_CN',
  204. '-D', 'master_doc=' + zh_config['index_filename'].replace('.rst', ''),
  205. str(docs_source_in_worktree),
  206. str(zh_output_dir)
  207. ], check=True, env=zh_env)
  208. # 恢复临时移动的英文版文件
  209. for original_file, temp_file in moved_files:
  210. try:
  211. if temp_file.exists():
  212. temp_file.rename(original_file)
  213. print(f" 恢复英文版文件: {temp_file} -> {original_file}")
  214. except Exception as e:
  215. print(f" 警告: 恢复文件时出错 {temp_file}: {e}")
  216. # 检查翻译文件是否生成
  217. translations_file = zh_output_dir / '_static' / 'translations.js'
  218. if translations_file.exists():
  219. print(f"✓ 中文翻译文件已生成: {translations_file}")
  220. # 检查翻译文件内容
  221. with open(translations_file, 'r', encoding='utf-8') as f:
  222. content = f.read()
  223. if 'zh_Hans_CN' in content or 'zh_CN' in content:
  224. print("✓ 翻译文件包含中文locale信息")
  225. else:
  226. print("⚠️ 翻译文件可能不包含正确的中文locale信息")
  227. else:
  228. print("⚠️ 中文翻译文件未生成")
  229. # 构建英文版文档
  230. print("构建英文版文档...")
  231. en_output_dir = output_dir / 'en'
  232. en_config = self.i18n_manager.get_language_config('en')
  233. en_env = os.environ.copy()
  234. en_env['SPHINX_MASTER_DOC'] = en_config['index_filename'].replace('.rst', '')
  235. en_env['SPHINX_MASTER_DOC_OVERRIDE'] = en_config['index_filename'].replace('.rst', '')
  236. en_env['SPHINX_LANGUAGE'] = 'en'
  237. # 确保英文locale环境变量
  238. en_env['LANG'] = 'en_US.UTF-8'
  239. en_env['LC_ALL'] = 'en_US.UTF-8'
  240. en_env['LC_CTYPE'] = 'en_US.UTF-8'
  241. # 英文版构建时临时移动中文版文件,避免Sphinx读取
  242. moved_files_en = []
  243. try:
  244. # 从配置文件读取分类列表
  245. cfg_path = docs_source_in_worktree / 'config.yaml'
  246. if cfg_path.exists():
  247. with open(cfg_path, 'r', encoding='utf-8') as f:
  248. cfg = yaml.safe_load(f) or {}
  249. categories = cfg.get('generation', {}).get('output_structure', [])
  250. for category in categories:
  251. # 临时移动中文版分类索引文件
  252. zh_index_file = docs_source_in_worktree / category / 'index_zh.rst'
  253. if zh_index_file.exists():
  254. temp_file = zh_index_file.with_suffix('.rst.temp')
  255. zh_index_file.rename(temp_file)
  256. moved_files_en.append((zh_index_file, temp_file))
  257. print(f" 临时移动中文版文件: {zh_index_file} -> {temp_file}")
  258. # 临时移动中文版主索引文件
  259. zh_main_index = docs_source_in_worktree / 'index_zh.rst'
  260. if zh_main_index.exists():
  261. temp_file = zh_main_index.with_suffix('.rst.temp')
  262. zh_main_index.rename(temp_file)
  263. moved_files_en.append((zh_main_index, temp_file))
  264. print(f" 临时移动中文版文件: {zh_main_index} -> {temp_file}")
  265. except Exception as e:
  266. print(f" 警告: 移动中文版文件时出错: {e}")
  267. # 英文版构建时排除中文文档
  268. en_env['SPHINX_EXCLUDE_PATTERNS'] = '*_zh.md'
  269. print(f"英文版构建环境变量:")
  270. print(f" LANG: {en_env.get('LANG', 'N/A')}")
  271. print(f" LC_ALL: {en_env.get('LC_ALL', 'N/A')}")
  272. print(f" SPHINX_LANGUAGE: {en_env.get('SPHINX_LANGUAGE', 'N/A')}")
  273. print(f" SPHINX_EXCLUDE_PATTERNS: {en_env.get('SPHINX_EXCLUDE_PATTERNS', 'N/A')}")
  274. subprocess.run([
  275. sys.executable, '-m', 'sphinx.cmd.build',
  276. '-b', 'html',
  277. '-D', 'master_doc=' + en_config['index_filename'].replace('.rst', ''),
  278. '-D', 'language=en',
  279. str(docs_source_in_worktree),
  280. str(en_output_dir)
  281. ], check=True, env=en_env)
  282. # 恢复临时移动的中文版文件
  283. for original_file, temp_file in moved_files_en:
  284. try:
  285. if temp_file.exists():
  286. temp_file.rename(original_file)
  287. print(f" 恢复中文版文件: {temp_file} -> {original_file}")
  288. except Exception as e:
  289. print(f" 警告: 恢复文件时出错 {temp_file}: {e}")
  290. # 检查翻译文件是否生成,如果没有则手动创建
  291. translations_file = en_output_dir / '_static' / 'translations.js'
  292. if translations_file.exists():
  293. print(f"✓ 英文翻译文件已生成: {translations_file}")
  294. # 检查翻译文件内容
  295. with open(translations_file, 'r', encoding='utf-8') as f:
  296. content = f.read()
  297. if 'en_US' in content or 'en' in content:
  298. print("✓ 翻译文件包含英文locale信息")
  299. else:
  300. print("⚠️ 翻译文件可能不包含正确的英文locale信息")
  301. else:
  302. print("⚠️ 英文翻译文件未生成,手动创建...")
  303. # 手动创建英文翻译文件
  304. en_translations_content = '''const TRANSLATIONS = {
  305. "locale": "en_US",
  306. "messages": {
  307. "Search": "Search",
  308. "Searching": "Searching",
  309. "Search Results": "Search Results",
  310. "Search finished, found %s page(s) matching the search query.": "Search finished, found %s page(s) matching the search query.",
  311. "Search didn't return any results. Please try again with different keywords.": "Search didn't return any results. Please try again with different keywords.",
  312. "Search Results for": "Search Results for",
  313. "Searching for": "Searching for",
  314. "Search": "Search",
  315. "Searching": "Searching",
  316. "Search Results": "Search Results"
  317. }
  318. };
  319. '''
  320. # 确保目录存在
  321. translations_file.parent.mkdir(parents=True, exist_ok=True)
  322. with open(translations_file, 'w', encoding='utf-8') as f:
  323. f.write(en_translations_content)
  324. print(f"✓ 已手动创建英文翻译文件: {translations_file}")
  325. # 合并文档集到统一目录
  326. print("合并文档集...")
  327. self._merge_docs_with_i18n(zh_output_dir, en_output_dir, output_dir)
  328. # 生成版本配置(注入项目源目录片段与复制文件规则)
  329. # 从 docs/source/config.yaml 读取 repository.projects_dir,并转换为仓库内相对路径片段
  330. projects_dir_web = ''
  331. copy_files_list = []
  332. try:
  333. cfg_path = docs_source_in_worktree / 'config.yaml'
  334. if cfg_path.exists():
  335. with open(cfg_path, 'r', encoding='utf-8') as f:
  336. repo_cfg = yaml.safe_load(f) or {}
  337. pdir = ((repo_cfg.get('repository', {}) or {}).get('projects_dir', '') or '').replace('\\','/')
  338. # 若是相对路径如 ../../project,则仅取末段 "project"
  339. if pdir:
  340. parts = [seg for seg in pdir.split('/') if seg and seg != '..' and seg != '.']
  341. if parts:
  342. projects_dir_web = '/'.join(parts[-1:])
  343. copy_files_list = ((repo_cfg.get('generation', {}) or {}).get('copy_files', []) or [])
  344. except Exception:
  345. pass
  346. self._generate_version_config(output_dir, version_config, projects_dir_web, copy_files_list)
  347. # 构建 PDF(仅使用增强版V2生成器,生成中英文两个版本)
  348. pdf_file = None
  349. from pdf_generator_enhanced_v2 import PDFGeneratorV2
  350. print("使用增强版V2 PDF生成器...")
  351. pdf_generator = PDFGeneratorV2(output_dir, output_dir / '_static')
  352. # 中文
  353. if pdf_generator.generate_pdf(project_name, language="zh"):
  354. static_dir = output_dir / '_static'
  355. candidate_pdf = static_dir / f'{project_name}.pdf'
  356. if candidate_pdf.exists():
  357. pdf_file = candidate_pdf
  358. print(f"✓ 中文PDF生成成功: {pdf_file}")
  359. else:
  360. print("⚠️ 中文PDF文件未找到")
  361. else:
  362. print("⚠️ 中文PDF生成失败")
  363. # 英文
  364. print("正在生成英文版本PDF...")
  365. if pdf_generator.generate_pdf(project_name, language="en"):
  366. static_dir = output_dir / '_static'
  367. # 英文 PDF 名称使用下划线替换空格
  368. en_pdf = static_dir / f"{project_name.replace(' ', '_')}_EN.pdf"
  369. if en_pdf.exists():
  370. print(f"✓ 英文PDF生成成功: {en_pdf}")
  371. else:
  372. print("⚠️ 英文PDF文件未找到")
  373. else:
  374. print("⚠️ 英文PDF生成失败")
  375. # 将 PDF 复制到 HTML 的 _static 目录,供在线下载
  376. static_dir = output_dir / '_static'
  377. static_dir.mkdir(exist_ok=True)
  378. if pdf_file and pdf_file.exists():
  379. target_pdf = static_dir / pdf_basename
  380. try:
  381. # 避免源与目标为同一文件时复制报错
  382. if pdf_file.resolve() != target_pdf.resolve():
  383. shutil.copy2(pdf_file, target_pdf)
  384. print(f"✓ 生成并复制 PDF: {pdf_file.name} -> {target_pdf}")
  385. else:
  386. print(f"✓ PDF 已在目标位置: {target_pdf}")
  387. except Exception as copy_err:
  388. print(f"⚠️ 复制 PDF 时出现问题(已忽略):{copy_err}")
  389. # 兼容默认名称,额外复制一份 sdk-docs.pdf,便于前端 file:// 环境无需获取项目信息
  390. fallback_pdf = static_dir / 'sdk-docs.pdf'
  391. try:
  392. shutil.copy2(pdf_file, fallback_pdf)
  393. except Exception:
  394. pass
  395. else:
  396. print("⚠️ 未生成 PDF,创建占位文件")
  397. # 创建一个占位PDF文件,避免下载按钮不显示
  398. placeholder_pdf = static_dir / 'sdk-docs.pdf'
  399. with open(placeholder_pdf, 'w', encoding='utf-8') as f:
  400. f.write("PDF文件正在生成中,请稍后重试...")
  401. # 写入项目信息,供前端读取文件名
  402. project_info = {
  403. 'projectName': project_name,
  404. 'pdfFileName': pdf_basename
  405. }
  406. with open(static_dir / 'project_info.json', 'w', encoding='utf-8') as f:
  407. json.dump(project_info, f, ensure_ascii=False)
  408. # 兼容 file:// 环境:同时输出 JS 版本,供页面直接读取
  409. try:
  410. with open(static_dir / 'project_info.js', 'w', encoding='utf-8') as f_js:
  411. f_js.write('window.projectInfo = ' + json.dumps(project_info, ensure_ascii=False) + ';\n')
  412. except Exception:
  413. pass
  414. return True
  415. except subprocess.CalledProcessError as e:
  416. print(f"✗ 构建失败: {e}")
  417. return False
  418. finally:
  419. # 恢复到原始目录(如果不是当前分支)
  420. if worktree_path != Path.cwd():
  421. os.chdir(self.project_root)
  422. def _generate_version_config(self, output_dir: Path, version_config: VersionConfig, projects_dir_web: str = '', copy_files: list = None):
  423. """生成版本切换配置文件
  424. projects_dir_web: 仓库内项目根路径(URL 片段),例如 "project" 或 "projects/examples"
  425. """
  426. config = self.load_versions_config()
  427. # 创建版本配置 JSON 文件 - 修复格式
  428. version_config_file = output_dir / 'version_config.json'
  429. with open(version_config_file, 'w', encoding='utf-8') as f:
  430. json.dump(config, f, ensure_ascii=False, indent=2)
  431. # 创建版本信息 HTML 文件
  432. version_info_file = output_dir / 'version_info.html'
  433. version_info_html = f"""
  434. <!DOCTYPE html>
  435. <html>
  436. <head>
  437. <meta charset="utf-8">
  438. <title>版本信息</title>
  439. </head>
  440. <body>
  441. <script>
  442. window.versionInfo = {{
  443. "name": "{version_config.name}",
  444. "display_name": "{version_config.display_name}",
  445. "branch": "{version_config.branch}",
  446. "url_path": "{version_config.url_path}",
  447. "description": "{version_config.description}"
  448. }};
  449. </script>
  450. </body>
  451. </html>"""
  452. with open(version_info_file, 'w', encoding='utf-8') as f:
  453. f.write(version_info_html)
  454. # 同时创建 _static 目录下的配置文件
  455. static_dir = output_dir / '_static'
  456. static_dir.mkdir(exist_ok=True)
  457. static_config_file = static_dir / 'version_config.json'
  458. with open(static_config_file, 'w', encoding='utf-8') as f:
  459. json.dump(config, f, ensure_ascii=False, indent=2)
  460. # 生成可直接加载的 JS,提供 window.versionInfo(含当前版本信息)
  461. version_info_js = static_dir / 'version_info.js'
  462. version_info_obj = {
  463. "name": version_config.name,
  464. "display_name": version_config.display_name,
  465. "branch": version_config.branch,
  466. "url_path": version_config.url_path,
  467. "description": version_config.description,
  468. "projectsDir": projects_dir_web or '',
  469. "copyFiles": copy_files or []
  470. }
  471. with open(version_info_js, 'w', encoding='utf-8') as f:
  472. f.write("window.versionInfo = " + json.dumps(version_info_obj, ensure_ascii=False) + ";\n")
  473. print(f"✓ 生成版本配置文件: {version_config_file}")
  474. print(f"✓ 生成静态配置文件: {static_config_file}")
  475. def _merge_docs_with_i18n(self, zh_dir: Path, en_dir: Path, output_dir: Path):
  476. """使用国际化配置合并中英文文档集"""
  477. import shutil
  478. # 创建输出目录
  479. output_dir.mkdir(parents=True, exist_ok=True)
  480. # 第一步:复制英文版文档(保持原名,无后缀表示英文)
  481. print("复制英文版文档...")
  482. self._copy_docs_with_html_fix(en_dir, output_dir, 'en')
  483. # 第二步:复制中文版文档(添加_zh后缀)
  484. print("复制中文版文档...")
  485. self._copy_docs_with_html_fix(zh_dir, output_dir, 'zh')
  486. # 清理临时目录
  487. shutil.rmtree(zh_dir, ignore_errors=True)
  488. shutil.rmtree(en_dir, ignore_errors=True)
  489. print("✓ 文档集合并完成")
  490. print(f" - 中文版文件:添加 _zh 后缀(如 index_zh.html, README_zh.html)")
  491. print(f" - 英文版文件:保持原名(如 index.html, README.html)")
  492. def _copy_docs_with_html_fix(self, source_dir: Path, target_dir: Path, language: str):
  493. """复制文档并修复HTML文件的语言配置"""
  494. import shutil
  495. # 确保目标目录存在
  496. target_dir.mkdir(parents=True, exist_ok=True)
  497. for item in source_dir.iterdir():
  498. if item.is_file():
  499. if item.name.endswith('.html'):
  500. # HTML文件需要修复语言配置
  501. if language == 'zh':
  502. # 中文版文件添加_zh后缀
  503. if item.stem.endswith('_zh'):
  504. new_name = item.name
  505. else:
  506. new_name = item.stem + '_zh.html'
  507. target_file = target_dir / new_name
  508. self._fix_html_language(item, target_file, 'zh')
  509. else:
  510. # 英文版文件保持原名
  511. target_file = target_dir / item.name
  512. self._fix_html_language(item, target_file, 'en')
  513. else:
  514. # 非HTML文件直接复制
  515. shutil.copy2(item, target_dir / item.name)
  516. elif item.is_dir() and not item.name.startswith('.'):
  517. # 只处理非隐藏目录,跳过 .doctrees 等Sphinx内部目录
  518. target_subdir = target_dir / item.name
  519. target_subdir.mkdir(exist_ok=True)
  520. for subitem in item.iterdir():
  521. if subitem.is_file():
  522. if subitem.name.endswith('.html'):
  523. # HTML文件需要修复语言配置
  524. if language == 'zh':
  525. # 中文版文件添加_zh后缀
  526. if subitem.stem.endswith('_zh'):
  527. new_name = subitem.name
  528. else:
  529. new_name = subitem.stem + '_zh.html'
  530. target_file = target_subdir / new_name
  531. self._fix_html_language(subitem, target_file, 'zh')
  532. else:
  533. # 英文版文件保持原名
  534. target_file = target_subdir / subitem.name
  535. self._fix_html_language(subitem, target_file, 'en')
  536. else:
  537. # 非HTML文件直接复制
  538. shutil.copy2(subitem, target_subdir / subitem.name)
  539. elif subitem.is_dir() and not subitem.name.startswith('.'):
  540. # 递归处理子目录,跳过隐藏目录
  541. self._copy_docs_with_html_fix(subitem, target_subdir / subitem.name, language)
  542. def _fix_html_language(self, source_file: Path, target_file: Path, language: str):
  543. """修复HTML文件的语言配置"""
  544. try:
  545. with open(source_file, 'r', encoding='utf-8') as f:
  546. content = f.read()
  547. # 修复语言属性
  548. if language == 'en':
  549. # 英文版修复
  550. content = re.sub(r'lang="zh-CN"', 'lang="en"', content)
  551. content = re.sub(r'placeholder="搜索文档"', 'placeholder="Search documentation"', content)
  552. content = re.sub(r'aria-label="搜索文档"', 'aria-label="Search documentation"', content)
  553. content = re.sub(r'aria-label="导航菜单"', 'aria-label="Navigation menu"', content)
  554. content = re.sub(r'aria-label="移动版导航菜单"', 'aria-label="Mobile navigation menu"', content)
  555. content = re.sub(r'aria-label="页面导航"', 'aria-label="Page navigation"', content)
  556. content = re.sub(r'aria-label="页脚"', 'aria-label="Footer"', content)
  557. # 修复链接指向
  558. content = re.sub(r'href="([^"]*)_zh\.html"', r'href="\1.html"', content)
  559. content = re.sub(r'href="([^"]*)/index_zh\.html"', r'href="\1/index.html"', content)
  560. # 修复目录结构中的链接
  561. content = re.sub(r'href="([^"]*)_zh\.html#', r'href="\1.html#', content)
  562. else:
  563. # 中文版保持原样,但确保语言属性正确
  564. content = re.sub(r'lang="en"', 'lang="zh-CN"', content)
  565. # 确保中文版链接指向中文版文件
  566. content = re.sub(r'href="([^"]*)(?<!_zh)\.html"', r'href="\1_zh.html"', content)
  567. content = re.sub(r'href="([^"]*)/index\.html"', r'href="\1/index_zh.html"', content)
  568. # 修复搜索框文本
  569. content = re.sub(r'placeholder="Search documentation"', 'placeholder="搜索文档"', content)
  570. content = re.sub(r'aria-label="Search documentation"', 'aria-label="搜索文档"', content)
  571. content = re.sub(r'aria-label="Navigation menu"', 'aria-label="导航菜单"', content)
  572. content = re.sub(r'aria-label="Mobile navigation menu"', 'aria-label="移动版导航菜单"', content)
  573. content = re.sub(r'aria-label="Page navigation"', 'aria-label="页面导航"', content)
  574. content = re.sub(r'aria-label="Footer"', 'aria-label="页脚"', content)
  575. # 写入修复后的文件
  576. with open(target_file, 'w', encoding='utf-8') as f:
  577. f.write(content)
  578. except Exception as e:
  579. print(f"⚠️ 修复HTML文件语言配置失败: {e}")
  580. # 如果修复失败,直接复制原文件
  581. shutil.copy2(source_file, target_file)
  582. def copy_build_result(self, worktree_path: Path, version_config: VersionConfig):
  583. """就地构建后无需复制,保持接口以兼容调用方"""
  584. target_dir = self.versions_dir / version_config.url_path
  585. if target_dir.exists():
  586. print(f"✓ 构建结果已在目标目录: {target_dir}")
  587. return True
  588. else:
  589. print(f"✗ 目标目录不存在: {target_dir}")
  590. return False
  591. def _generate_pdf_latex(self, docs_source_in_worktree: Path, version_config: VersionConfig) -> Optional[Path]:
  592. """使用传统LaTeX方法生成PDF(作为回退方案)"""
  593. self._ensure_pdf_dependencies()
  594. pdf_file = None
  595. try:
  596. # 尝试 latexpdf 构建器
  597. latexpdf_dir = self.build_root / 'latexpdf' / version_config.url_path
  598. print(f"尝试使用 latexpdf 构建: {latexpdf_dir}")
  599. subprocess.run([
  600. sys.executable, '-m', 'sphinx.cmd.build',
  601. '-b', 'latexpdf',
  602. str(docs_source_in_worktree),
  603. str(latexpdf_dir)
  604. ], check=True)
  605. # 预期输出:conf.py 设定主文档名 sdk-docs.tex -> sdk-docs.pdf
  606. candidate = latexpdf_dir / 'sdk-docs.pdf'
  607. if candidate.exists():
  608. pdf_file = candidate
  609. else:
  610. # 回退查找任意 pdf
  611. pdf_candidates = list(latexpdf_dir.glob('*.pdf'))
  612. if pdf_candidates:
  613. pdf_file = pdf_candidates[0]
  614. except subprocess.CalledProcessError:
  615. # 回退到 latex + 编译链
  616. latex_dir = self.build_root / 'latex' / version_config.url_path
  617. print(f"latexpdf 失败,回退到 LaTeX 构建: {latex_dir}")
  618. subprocess.run([
  619. sys.executable, '-m', 'sphinx.cmd.build',
  620. '-b', 'latex',
  621. str(docs_source_in_worktree),
  622. str(latex_dir)
  623. ], check=True)
  624. try:
  625. tex_files = list(latex_dir.glob('*.tex'))
  626. main_tex = None
  627. # 优先使用 conf.py 指定的 sdk-docs.tex
  628. candidate_tex = latex_dir / 'sdk-docs.tex'
  629. if candidate_tex.exists():
  630. main_tex = candidate_tex
  631. elif tex_files:
  632. main_tex = tex_files[0]
  633. if main_tex:
  634. # latexmk -> tectonic -> pdflatex
  635. compiled = False
  636. try:
  637. subprocess.run(['latexmk', '-pdf', '-silent', '-interaction=nonstopmode', str(main_tex.name)], cwd=str(latex_dir), check=True)
  638. compiled = True
  639. except Exception:
  640. try:
  641. subprocess.run(['tectonic', str(main_tex.name)], cwd=str(latex_dir), check=True)
  642. compiled = True
  643. except Exception:
  644. try:
  645. subprocess.run(['pdflatex', '-interaction=nonstopmode', str(main_tex.name)], cwd=str(latex_dir), check=True)
  646. subprocess.run(['pdflatex', '-interaction=nonstopmode', str(main_tex.name)], cwd=str(latex_dir), check=True)
  647. compiled = True
  648. except Exception:
  649. pass
  650. if compiled:
  651. # 优先 sdk-docs.pdf
  652. candidate_pdf = latex_dir / 'sdk-docs.pdf'
  653. if candidate_pdf.exists():
  654. pdf_file = candidate_pdf
  655. else:
  656. pdf_candidates = list(latex_dir.glob('*.pdf'))
  657. if pdf_candidates:
  658. pdf_file = pdf_candidates[0]
  659. except Exception as e:
  660. print(f"⚠️ LaTeX 回退编译失败: {e}")
  661. return pdf_file
  662. def _ensure_pdf_dependencies(self):
  663. """尽力确保本机具备 PDF 构建依赖。优先 tectonic,其次 latexmk/texlive,再次 pdflatex。
  664. 以非交互方式尝试安装,失败仅警告不报错。
  665. """
  666. def _exists(cmd: str) -> bool:
  667. return which(cmd) is not None
  668. have_tool = _exists('tectonic') or _exists('latexmk') or _exists('pdflatex')
  669. have_xelatex = _exists('xelatex')
  670. if have_tool and have_xelatex:
  671. return
  672. system = platform.system().lower()
  673. print("尝试安装 PDF 构建依赖...")
  674. try:
  675. if system == 'windows':
  676. if _exists('choco'):
  677. try:
  678. subprocess.run(['choco', 'install', 'tectonic', '-y'], check=False)
  679. except Exception:
  680. pass
  681. try:
  682. subprocess.run(['choco', 'install', 'miktex', '-y'], check=False)
  683. except Exception:
  684. pass
  685. elif system == 'linux':
  686. if _exists('apt-get'):
  687. subprocess.run(['sudo', 'apt-get', 'update'], check=False)
  688. subprocess.run(['sudo', 'apt-get', 'install', '-y', 'tectonic'], check=False)
  689. subprocess.run(['sudo', 'apt-get', 'install', '-y', 'latexmk', 'texlive-latex-recommended', 'texlive-latex-extra', 'texlive-fonts-recommended', 'texlive-xetex', 'fonts-noto-cjk'], check=False)
  690. elif system == 'darwin':
  691. if _exists('brew'):
  692. subprocess.run(['brew', 'install', 'tectonic'], check=False)
  693. subprocess.run(['brew', 'install', 'basictex'], check=False)
  694. subprocess.run(['sudo', 'tlmgr', 'update', '--self'], check=False)
  695. subprocess.run(['sudo', 'tlmgr', 'install', 'latexmk', 'xetex'], check=False)
  696. except Exception as e:
  697. print(f"PDF 依赖安装尝试失败: {e}")
  698. def cleanup_worktree(self, worktree_path: Path):
  699. """清理 worktree:仅对 source_build/worktrees 下的有效 worktree 执行删除"""
  700. if not worktree_path.exists():
  701. return
  702. # 仅在我们的临时 worktrees 根目录下才允许删除
  703. try:
  704. worktree_root = self.worktrees_dir.resolve()
  705. candidate = worktree_path.resolve()
  706. is_under_root = str(candidate).startswith(str(worktree_root))
  707. except Exception:
  708. is_under_root = False
  709. if not is_under_root:
  710. # 避免误删非临时目录(例如当前仓库根或任意外部路径)
  711. return
  712. # 在删除之前确认它是一个已登记的 git worktree
  713. is_git_worktree = False
  714. try:
  715. listed = subprocess.run(['git', 'worktree', 'list'], capture_output=True, text=True, check=True).stdout
  716. is_git_worktree = str(candidate) in listed
  717. except Exception:
  718. pass
  719. if is_git_worktree:
  720. # 尝试优先用 git worktree remove --force
  721. for args in (["git", "worktree", "remove", "--force", str(candidate)],
  722. ["git", "worktree", "remove", str(candidate)]):
  723. try:
  724. subprocess.run(args, check=True, capture_output=True)
  725. print(f"✓ 清理 worktree: {worktree_path}")
  726. return
  727. except subprocess.CalledProcessError:
  728. continue
  729. # 兜底:非登记 worktree 或命令失败,做文件系统级别删除
  730. shutil.rmtree(candidate, ignore_errors=True)
  731. def build_all_versions(self, clean=False):
  732. """构建所有版本"""
  733. print("=" * 60)
  734. print("开始构建所有版本")
  735. print("=" * 60)
  736. if clean:
  737. print("清理构建目录...")
  738. if self.build_root.exists():
  739. shutil.rmtree(self.build_root)
  740. # 确保构建目录存在
  741. self.build_root.mkdir(parents=True, exist_ok=True)
  742. self.versions_dir.mkdir(parents=True, exist_ok=True)
  743. # 加载版本配置
  744. versions = self.get_version_configs()
  745. print(f"✓ 加载版本配置: {[v.name for v in versions]}")
  746. success_count = 0
  747. total_count = len(versions)
  748. for version_config in versions:
  749. print("\n" + "=" * 40)
  750. print(f"构建版本: {version_config.display_name} ({version_config.branch})")
  751. print("=" * 40)
  752. # 创建或获取 worktree
  753. worktree_path = self.create_worktree(version_config)
  754. if not worktree_path:
  755. print(f"✗ 无法为版本 {version_config.display_name} 创建 worktree")
  756. continue
  757. try:
  758. # 构建文档
  759. if self.build_docs_in_worktree(worktree_path, version_config):
  760. # 复制构建结果
  761. if self.copy_build_result(worktree_path, version_config):
  762. success_count += 1
  763. print(f"✓ 版本 {version_config.display_name} 构建成功")
  764. else:
  765. print(f"✗ 版本 {version_config.display_name} 复制失败")
  766. else:
  767. print(f"✗ 版本 {version_config.display_name} 构建失败")
  768. finally:
  769. # 清理 worktree
  770. self.cleanup_worktree(worktree_path)
  771. # 创建统一入口页面,指向新的根目录结构
  772. self.create_unified_index()
  773. # 在 html 根目录下创建 index.html 指向默认版本
  774. self.create_versions_root_index()
  775. print("\n" + "=" * 60)
  776. print(f"构建完成: {success_count}/{total_count} 个版本成功")
  777. print("=" * 60)
  778. return success_count == total_count
  779. def create_unified_index(self):
  780. """创建统一的文档入口页面"""
  781. config = self.load_versions_config()
  782. versions = config.get('versions', [])
  783. default_version = config.get('default_version', '')
  784. latest_version = config.get('latest_version', '')
  785. # 找到默认版本的 URL 路径
  786. default_url = 'latest'
  787. for version in versions:
  788. if version['name'] == default_version:
  789. default_url = version['url_path']
  790. break
  791. # 创建根目录的 index.html
  792. index_html = f"""<!DOCTYPE html>
  793. <html lang="zh-CN">
  794. <head>
  795. <meta charset="UTF-8">
  796. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  797. <title>SDK 文档</title>
  798. <meta http-equiv="refresh" content="0; url=./versions/{default_url}/index.html">
  799. <style>
  800. body {{
  801. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  802. display: flex;
  803. justify-content: center;
  804. align-items: center;
  805. height: 100vh;
  806. margin: 0;
  807. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  808. color: white;
  809. }}
  810. .container {{
  811. text-align: center;
  812. background: rgba(255, 255, 255, 0.1);
  813. padding: 40px;
  814. border-radius: 12px;
  815. backdrop-filter: blur(10px);
  816. }}
  817. .spinner {{
  818. border: 3px solid rgba(255, 255, 255, 0.3);
  819. border-top: 3px solid white;
  820. border-radius: 50%;
  821. width: 40px;
  822. height: 40px;
  823. animation: spin 1s linear infinite;
  824. margin: 0 auto 20px;
  825. }}
  826. @keyframes spin {{
  827. 0% {{ transform: rotate(0deg); }}
  828. 100% {{ transform: rotate(360deg); }}
  829. }}
  830. h1 {{
  831. margin: 0 0 10px 0;
  832. font-size: 24px;
  833. }}
  834. p {{
  835. margin: 0;
  836. opacity: 0.9;
  837. }}
  838. a {{
  839. color: white;
  840. text-decoration: underline;
  841. }}
  842. </style>
  843. </head>
  844. <body>
  845. <div class="container">
  846. <div class="spinner"></div>
  847. <h1>SDK 文档</h1>
  848. <p>正在跳转到文档首页...</p>
  849. <p><a href="./versions/{default_url}/index.html">如果页面没有自动跳转,请点击这里</a></p>
  850. </div>
  851. </body>
  852. </html>"""
  853. index_file = self.build_root / 'html' / 'index.html'
  854. index_file.parent.mkdir(parents=True, exist_ok=True)
  855. with open(index_file, 'w', encoding='utf-8') as f:
  856. f.write(index_html)
  857. print(f"✓ 创建统一入口页面: {index_file}")
  858. def create_versions_root_index(self):
  859. """在versions目录下创建根页面"""
  860. config = self.load_versions_config()
  861. versions = config.get('versions', [])
  862. default_version = config.get('default_version', '')
  863. latest_version = config.get('latest_version', '')
  864. # 找到默认版本的 URL 路径
  865. default_url = 'latest'
  866. for version in versions:
  867. if version['name'] == default_version:
  868. default_url = version['url_path']
  869. break
  870. # 创建versions目录的index.html
  871. versions_index_html = f"""<!DOCTYPE html>
  872. <html lang="zh-CN">
  873. <head>
  874. <meta charset="UTF-8">
  875. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  876. <title>SDK 文档 - 版本列表</title>
  877. <meta http-equiv="refresh" content="0; url=./{default_url}/index.html">
  878. <style>
  879. body {{
  880. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  881. display: flex;
  882. justify-content: center;
  883. align-items: center;
  884. height: 100vh;
  885. margin: 0;
  886. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  887. color: white;
  888. }}
  889. .container {{
  890. text-align: center;
  891. background: rgba(255, 255, 255, 0.1);
  892. padding: 40px;
  893. border-radius: 12px;
  894. backdrop-filter: blur(10px);
  895. }}
  896. .spinner {{
  897. border: 3px solid rgba(255, 255, 255, 0.3);
  898. border-top: 3px solid white;
  899. border-radius: 50%;
  900. width: 40px;
  901. height: 40px;
  902. animation: spin 1s linear infinite;
  903. margin: 0 auto 20px;
  904. }}
  905. @keyframes spin {{
  906. 0% {{ transform: rotate(0deg); }}
  907. 100% {{ transform: rotate(360deg); }}
  908. }}
  909. h1 {{
  910. margin: 0 0 10px 0;
  911. font-size: 24px;
  912. }}
  913. p {{
  914. margin: 0;
  915. opacity: 0.9;
  916. }}
  917. a {{
  918. color: white;
  919. text-decoration: underline;
  920. }}
  921. </style>
  922. </head>
  923. <body>
  924. <div class="container">
  925. <div class="spinner"></div>
  926. <h1>SDK 文档 - 版本列表</h1>
  927. <p>正在跳转到文档首页...</p>
  928. <p><a href="./{default_url}/index.html">如果页面没有自动跳转,请点击这里</a></p>
  929. </div>
  930. </body>
  931. </html>"""
  932. versions_index_file = self.versions_dir / 'index.html'
  933. versions_index_file.parent.mkdir(parents=True, exist_ok=True)
  934. with open(versions_index_file, 'w', encoding='utf-8') as f:
  935. f.write(versions_index_html)
  936. print(f"✓ 创建versions目录根页面: {versions_index_file}")
  937. def main():
  938. """主函数"""
  939. parser = argparse.ArgumentParser(description="中央构建管理器")
  940. parser.add_argument('--clean', action='store_true', help='清理构建目录')
  941. parser.add_argument('--list-versions', action='store_true', help='列出所有版本')
  942. parser.add_argument('--check-config', action='store_true', help='检查版本配置')
  943. args = parser.parse_args()
  944. try:
  945. manager = BuildManager()
  946. if args.list_versions:
  947. versions = manager.get_version_configs()
  948. print("版本列表:")
  949. for version in versions:
  950. print(f" - {version.display_name} ({version.name}) -> {version.branch}")
  951. return
  952. if args.check_config:
  953. config = manager.load_versions_config()
  954. print("版本配置检查:")
  955. print(f" 默认版本: {config.get('default_version', 'N/A')}")
  956. print(f" 最新版本: {config.get('latest_version', 'N/A')}")
  957. print(f" 版本数量: {len(config.get('versions', []))}")
  958. return
  959. # 构建所有版本
  960. success = manager.build_all_versions(clean=args.clean)
  961. if success:
  962. print("\n🎉 所有版本构建成功!")
  963. print(f"📁 文档位置: {manager.versions_dir}")
  964. else:
  965. print("\n❌ 部分版本构建失败!")
  966. sys.exit(1)
  967. except Exception as e:
  968. print(f"✗ 构建管理器错误: {e}")
  969. sys.exit(1)
  970. if __name__ == "__main__":
  971. main()