#!/usr/bin/env python3
"""
Docs as Code — 构建入口
读取 project.yaml → 逐章节处理 @import → Markdown → HTML → 图片内嵌
→ 组装 → 注入锚点 → 提取目录 → 模板渲染 → 自包含 HTML 报告。
用法:
python doc_builder/build.py
输出:
output/<标题>.html (自包含 HTML,可离线分发)
"""
import base64
import csv
import importlib.util
import io
import json
import re
import sys
from datetime import date
from pathlib import Path
import markdown
import yaml
from jinja2 import Environment, FileSystemLoader, select_autoescape
# ---------- 路径配置 ----------
PROJECT_ROOT = Path(__file__).resolve().parent.parent
CHAPTERS_DIR = PROJECT_ROOT / "chapters"
ASSETS_DIR = PROJECT_ROOT / "assets"
OUTPUT_DIR = PROJECT_ROOT / "output"
BUILDER_DIR = PROJECT_ROOT / "doc_builder"
TEMPLATES_DIR = BUILDER_DIR / "templates"
THEMES_DIR = BUILDER_DIR / "themes"
RENDERERS_DIR = BUILDER_DIR / "renderers"
CHECKS_DIR = BUILDER_DIR / "checks"
# ---------- 配置加载 ----------
def load_config():
path = PROJECT_ROOT / "project.yaml"
if not path.exists():
raise FileNotFoundError("找不到 project.yaml")
with open(path, "r", encoding="utf-8") as f:
config = yaml.safe_load(f) or {}
config.setdefault("title", "未命名文档")
config.setdefault("subtitle", "")
config.setdefault("author", "")
config.setdefault("version", "")
config.setdefault("doc_type", "技术文档")
config.setdefault("logo", "")
config.setdefault("lang", "zh-CN")
config.setdefault("date", date.today().isoformat())
return config
# ---------- 插件发现 ----------
def load_plugin_modules(directory):
"""扫描目录下的 *.py 文件并导入为模块字典。"""
modules = {}
if not directory.exists():
return modules
for py_file in sorted(directory.glob("*.py")):
if py_file.name.startswith("_"):
continue
try:
spec = importlib.util.spec_from_file_location(py_file.stem, py_file)
if spec is None or spec.loader is None:
continue
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
modules[py_file.stem] = mod
print(f" 已加载: {py_file.stem}")
except Exception as e:
print(f" 警告: 加载 {py_file.name} 失败: {e}")
return modules
# ---------- @import 处理 ----------
IMPORT_RE = re.compile(r'^@import\s+"([^"]+)"(?:\s+using\s+(\S+))?\s*$', re.MULTILINE)
def resolve_import_path(raw, base_dir):
path = Path(raw)
if path.is_absolute():
return path
return (base_dir / path).resolve()
def default_data_renderer(filepath):
"""默认数据文件渲染:CSV → HTML 表格,YAML/JSON → 代码块。"""
ext = filepath.suffix.lower()
if ext == ".csv":
with open(filepath, newline="", encoding="utf-8") as f:
reader = csv.reader(f)
rows = list(reader)
if not rows:
return ""
lines = ["
"]
lines.append("" + "".join(f"| {c} | " for c in rows[0]) + "
")
for row in rows[1:]:
lines.append("" + "".join(f"| {c} | " for c in row) + "
")
lines.append("
")
return "".join(lines)
elif ext in (".yaml", ".yml"):
with open(filepath, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return f"{yaml.dump(data, allow_unicode=True)}
"
elif ext == ".json":
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
return f"{json.dumps(data, ensure_ascii=False, indent=2)}
"
else:
content = filepath.read_text(encoding="utf-8")
return f"{content}
"
def render_with_module(renderers, renderer_name, filepath):
"""调用指定渲染器。"""
if renderer_name not in renderers:
raise ValueError(f"找不到指定渲染器:{renderer_name}")
func = getattr(renderers[renderer_name], "render", None)
if not callable(func):
raise ValueError(f"{renderer_name} 没有 render(content: str) -> str 函数")
return func(str(filepath))
def rewrite_image_paths(text, source_dir):
"""把被导入 Markdown 中的相对图片路径改为绝对路径,供后续 base64 嵌入。"""
def repl(match):
alt = match.group(1)
src = match.group(2)
if src.startswith(("http://", "https://", "data:")) or Path(src).is_absolute():
return match.group(0)
abs_path = (source_dir / src).resolve()
return f''
return re.sub(r'!\[([^\]]*)\]\(([^)]+)\)', repl, text)
def process_imports(text, base_dir, renderers, _imported=None):
"""扫描并替换 @import 指令。支持 .md 注入和数据文件导入。"""
if _imported is None:
_imported = set()
def repl(match):
raw = match.group(1)
renderer_name = match.group(2)
target = resolve_import_path(raw, base_dir)
# 外部 Markdown 导入
if raw.endswith(".md"):
if target in _imported:
raise RuntimeError(f"检测到循环 @import:{target}")
_imported.add(target)
if not target.exists():
raise FileNotFoundError(f"找不到要导入的 Markdown 文件:{target}")
md = target.read_text(encoding="utf-8")
md = rewrite_image_paths(md, target.parent)
md = process_imports(md, target.parent, renderers, _imported)
return md
# 数据导入
if not target.exists():
raise FileNotFoundError(f"找不到要导入的数据文件:{target}")
if renderer_name:
renderer_name = renderer_name.removesuffix(".py")
return render_with_module(renderers, renderer_name, target)
return default_data_renderer(target)
return IMPORT_RE.sub(repl, text)
# ---------- 图片属性 {w=50%} ----------
IMAGE_ATTR_RE = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)\{([^}]*)\}')
def parse_attrs(attr_str):
attrs = {}
for part in attr_str.split(","):
part = part.strip()
if "=" in part:
k, v = part.split("=", 1)
attrs[k.strip()] = v.strip()
return attrs
def process_image_attrs(text):
"""处理 {w=50%} 图片属性语法,转换为 HTML img 标签。"""
def repl(match):
alt = match.group(1)
src = match.group(2)
attr_str = match.group(3)
attrs = parse_attrs(attr_str)
style = ""
if "w" in attrs:
style = f'width:{attrs["w"]};'
cls = attrs.get("class", "")
cls_attr = f' class="{cls}"' if cls else ""
style_attr = f' style="{style}"' if style else ""
return f'
'
return IMAGE_ATTR_RE.sub(repl, text)
# ---------- KaTeX 预处理 ----------
def preprocess_katex(text):
"""
将 Markdown 中的 LaTeX 公式包装为原始 HTML,
防止 Markdown 解析器错误解释公式中的 _ * 等字符。
"""
# 块级公式 $$...$$
def protect_display(match):
latex = match.group(1)
return f'$${latex}$$
'
text = re.sub(r'\$\$\s*(.+?)\s*\$\$', protect_display, text, flags=re.DOTALL)
# 行内公式 $...$
def protect_inline(match):
latex = match.group(1)
return f'${latex}$'
text = re.sub(r'(?= len(lines):
out_lines.append(line)
i += 1
continue
content = "".join(lines[start:j]).rstrip("\n")
rendered = func(content)
if not rendered.endswith("\n"):
rendered += "\n"
out_lines.append(rendered)
# 跳过 fence 后的换行
if j + 1 < len(lines) and lines[j + 1] == "\n":
i = j + 2
else:
i = j + 1
return "".join(out_lines)
# ---------- Markdown → HTML ----------
def markdown_to_html(text):
md = markdown.Markdown(extensions=[
"extra",
"tables",
"fenced_code",
"toc",
])
return md.convert(text)
# ---------- 图片 base64 内嵌(HTML 级别) ----------
IMG_SRC_RE = re.compile(r'
]*?)src=["\']([^"\']+)["\']([^>]*)>', re.IGNORECASE)
MIME_TABLE = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".webp": "image/webp",
}
def guess_mime(ext):
return MIME_TABLE.get(ext.lower(), "application/octet-stream")
def embed_images(html, base_dirs):
"""扫描 HTML 中的 img 标签,将本地图片替换为 base64 data URI。"""
def resolve_src(src):
if Path(src).is_absolute():
path = Path(src)
if path.exists():
return path
return None
for base in base_dirs:
path = base / src
if path.exists():
return path
return None
def repl(match):
prefix = match.group(1)
src = match.group(2)
suffix = match.group(3)
if src.startswith(("http://", "https://", "data:")):
return match.group(0)
path = resolve_src(src)
if path is None:
print(f" [警告] 找不到图片,保留原路径:{src}")
return match.group(0)
try:
mime = guess_mime(path.suffix)
data = path.read_bytes()
b64 = base64.b64encode(data).decode("ascii")
return f'
'
except Exception as e:
print(f" [警告] 图片 base64 编码失败({src}):{e}")
return match.group(0)
return IMG_SRC_RE.sub(repl, html)
# ---------- 目录与锚点 ----------
def slugify(text):
"""生成 HTML 锚点 ID。"""
anchor = re.sub(r'[^\w\s一-鿿-]', '', text)
return anchor.strip().replace(" ", "-")[:50]
def generate_toc(html):
"""从 HTML 中提取 H1-H4 标题,生成目录。"""
toc = []
for m in re.finditer(r']*>(.*?)', html, re.DOTALL):
level = int(m.group(1))
text = re.sub(r'<.*?>', '', m.group(2)).strip()
toc.append({"level": level, "text": text, "anchor": slugify(text)})
return toc
def inject_anchors(html):
"""为所有 H1-H4 标签注入 id 属性,使侧边栏目录可跳转。"""
def repl(match):
level = match.group(1)
attrs = match.group(2)
inner = match.group(3)
anchor = slugify(re.sub(r'<.*?>', '', inner).strip())
return f'{inner}'
return re.sub(
r']*)>(.*?)',
repl,
html,
flags=re.DOTALL,
)
# ---------- Logo 处理 ----------
def find_logo(logo_path_str):
"""定位 logo 文件;需在 project.yaml 明确指定 logo 字段。"""
if not logo_path_str:
return None
path = Path(logo_path_str)
if path.is_absolute():
return path
return PROJECT_ROOT / path
def embed_logo(logo_path, max_width=400):
"""压缩 logo 并返回 base64 data URI。"""
if logo_path is None:
return None
try:
from PIL import Image
except ImportError:
print(" 提示: 未安装 Pillow,跳过 logo 处理")
return None
try:
img = Image.open(logo_path)
w, h = img.size
if w > max_width:
ratio = max_width / w
img = img.resize((max_width, int(h * ratio)), Image.Resampling.LANCZOS)
ext = logo_path.suffix.lower()
if ext == ".svg":
data = logo_path.read_bytes()
b64 = base64.b64encode(data).decode("ascii")
return f"data:image/svg+xml;base64,{b64}"
buf = io.BytesIO()
if img.mode in ("RGBA", "P"):
img.save(buf, format="PNG", optimize=True)
mime = "image/png"
else:
img = img.convert("RGB")
img.save(buf, format="JPEG", optimize=True, quality=90)
mime = "image/jpeg"
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
return f"data:{mime};base64,{b64}"
except Exception as e:
print(f" 警告: logo 处理失败: {e}")
return None
# ---------- 模板渲染 ----------
def inline_css():
"""读取主题 CSS。"""
css_path = THEMES_DIR / "report.css"
if css_path.exists():
return css_path.read_text(encoding="utf-8")
return ""
def render_template(config, content, toc, logo_data_uri=None):
env = Environment(
loader=FileSystemLoader(TEMPLATES_DIR),
autoescape=select_autoescape(["html", "xml"]),
)
template = env.get_template("report.html")
css = inline_css()
return template.render(
title=config["title"],
subtitle=config["subtitle"],
author=config["author"],
version=config["version"],
doc_type=config["doc_type"],
logo=logo_data_uri,
date=config["date"],
content=content,
toc=toc,
css=css,
)
# ---------- 检查脚本 ----------
def run_checks(checks):
issues = []
for name, module in checks.items():
func = getattr(module, "check", None)
if not callable(func):
continue
try:
result = func(str(PROJECT_ROOT))
if result:
issues.extend(result)
except Exception as e:
print(f" 警告: 检查脚本 {name} 执行失败: {e}")
if issues:
print("检查发现问题:")
for item in issues:
print(f" - {item}")
# ---------- 主入口 ----------
def main():
print("=== Docs as Code 构建 ===")
print()
# 0. 加载配置
config = load_config()
print(f"[配置] {config['title']} — {config['version']}")
print(f" 章节数: {len(config.get('chapters', []))}")
# 0a. 加载插件
print("[插件] 加载扩展模块 ...")
renderers = load_plugin_modules(RENDERERS_DIR)
checks = load_plugin_modules(CHECKS_DIR)
# 0b. 运行检查
if config.get("checks", True) and checks:
print("[检查] 运行检查脚本 ...")
run_checks(checks)
# 1. 逐章节处理
chapters = config.get("chapters", [])
if not chapters:
sys.exit("错误: project.yaml 中未定义 chapters 列表")
html_parts = []
for ch_file in chapters:
ch_path = CHAPTERS_DIR / ch_file
if not ch_path.exists():
print(f" 警告: 章节文件不存在,跳过: {ch_file}")
continue
text = ch_path.read_text(encoding="utf-8")
# @import 处理
text = process_imports(text, ch_path.parent, renderers)
# 图片属性处理
text = process_image_attrs(text)
# KaTeX 预处理
text = preprocess_katex(text)
# 自定义代码块渲染
text = render_custom_codeblocks_md(text, renderers)
# Markdown → HTML
chapter_html = markdown_to_html(text)
# 图片 base64 内嵌
base_dirs = [ch_path.parent, ASSETS_DIR, PROJECT_ROOT]
chapter_html = embed_images(chapter_html, base_dirs)
html_parts.append(chapter_html)
full_html = "\n\n".join(html_parts)
print(f" HTML 总字符数: {len(full_html)}")
# 2. 注入锚点 + 提取目录
full_html = inject_anchors(full_html)
toc = generate_toc(full_html)
print(f" 目录条目数: {len(toc)}")
# 3. Logo 处理
logo_path = find_logo(config.get("logo", ""))
logo_data_uri = embed_logo(logo_path) if logo_path else None
if logo_data_uri:
print(" logo: 已内嵌")
else:
print(" logo: 未配置,封面将不显示 logo")
# 4. 模板渲染 + 输出
output_filename = re.sub(r'[^\w\-.]', '_', config["title"]) + ".html"
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
output_path = OUTPUT_DIR / output_filename
final_html = render_template(config, full_html, toc, logo_data_uri)
output_path.write_text(final_html, encoding="utf-8")
print(f"\n构建完成:{output_path}")
print(f"文件大小: {output_path.stat().st_size:,} 字节")
if __name__ == "__main__":
main()