Files
02-ima/scripts/build_web.py
T

167 lines
7.6 KiB
Python

#!/usr/bin/env python3
"""Build the offline deck using only Python's standard library."""
import argparse
import io
import zipfile
import base64
import html
import json
import mimetypes
import re
from collections import Counter
from html.parser import HTMLParser
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "index.html"
MATERIAL_OUTPUT = ROOT / "outputs/培训练习材料.zip"
class Markup(HTMLParser):
def __init__(self, text):
super().__init__(convert_charrefs=True)
self.ids = []
self.sections = []
self.resources = []
self.feed(text)
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if "id" in attrs:
self.ids.append(attrs["id"])
if tag == "section":
self.sections.append(attrs)
if tag in ("img", "script", "iframe", "video", "audio", "source"):
if attrs.get("src"):
self.resources.append(attrs["src"])
def require(condition, message):
if not condition:
raise ValueError(message)
def extract(text, tag):
blocks = re.findall(rf"<{tag}>(.*?)</{tag}>", text, re.S)
return re.sub(rf"<{tag}>.*?</{tag}>", "", text, flags=re.S), blocks
def scoped_css(css, page_id):
"""Require every local selector to begin at this page's root."""
# Page styles deliberately use ordinary rules; global animations belong in theme.css.
clean = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
rules = re.findall(r"([^{}]+)\{([^{}]*)\}", clean)
require(not re.sub(r"[^{}]+\{[^{}]*\}", "", clean).strip(),
f"{page_id}: 页内样式不支持嵌套规则,请将公共媒体规则放入 theme.css")
for selectors, _ in rules:
require(all(re.match(r"^:scope(?=[\s.#:[>+~]|$)", selector.strip())
for selector in selectors.split(",")),
f"{page_id}: 每个专用样式选择器必须以 :scope 开头")
require(not re.search(r":scope\s*[+~]", selectors),
f"{page_id}: 页内样式不能选择其他页面")
return css.replace(":scope", f':where([data-page="{page_id}"])')
def build():
ids = json.loads((ROOT / "src/deck.json").read_text())
require(isinstance(ids, list) and ids, "deck.json 必须是非空页面 ID 列表")
require(all(isinstance(x, str) and re.fullmatch(r"[a-z][a-z0-9-]*", x) for x in ids),
"页面 ID 只能包含小写字母、数字和连字符")
require(len(ids) == len(set(ids)), "deck.json 中存在重复页面 ID")
pages, styles, scripts = [], [], []
styles.append((ROOT / "src/styles/theme.css").read_text())
materials = [{"name": p.name, "content": p.read_text()} for p in sorted((ROOT / "materials").glob("*.md"))]
require(len(materials) == 5, "练习资料必须包含5份Markdown文件")
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
for item in materials:
info = zipfile.ZipInfo(item["name"], date_time=(1980, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_DEFLATED
archive.writestr(info, item["content"])
material = buffer.getvalue()
scripts.append("const MATERIALS=" + json.dumps(materials, ensure_ascii=False) + ";")
scripts.append("const MATERIAL_ZIP=" + json.dumps(base64.b64encode(material).decode()) + ";")
scripts.append((ROOT / "src/runtime/player.js").read_text())
image_types = {}
def inline_asset(match):
name = match[1]
path = (ROOT / "assets" / name).resolve()
require((ROOT / "assets").resolve() in path.parents, f"图片路径越界: {name}")
require(path.is_file(), f"图片不存在: {name}")
mime = ("image/" + image_types[name] if name in image_types
else mimetypes.guess_type(name)[0])
require(mime and mime.startswith("image/"), f"无法识别图片类型: {name}")
return "data:" + mime + ";base64," + base64.b64encode(path.read_bytes()).decode()
for number, page_id in enumerate(ids, 1):
path = ROOT / "src/slides" / (page_id + ".html")
text = path.read_text()
text, page_styles = extract(text, "style")
text, page_scripts = extract(text, "script")
parsed = Markup(text)
require(len(parsed.sections) == 1, f"{path.name}: 必须恰好有一个 section")
attrs = parsed.sections[0]
require("slide" in attrs.get("class", "").split(), f"{path.name}: 缺少 slide 类")
require(all(attrs.get(key) for key in ("data-title", "data-chapter", "data-note")),
f"{path.name}: 缺少标题、章节或讲稿备注")
require(not any(key in attrs for key in ("id", "data-page", "aria-label", "aria-hidden")),
f"{path.name}: 页码、标识和可见状态由构建生成,请移除手写属性")
require("active" not in attrs["class"].split(), f"{path.name}: 不要手写 active")
title = html.escape(attrs["data-title"], quote=True)
text = text.replace("<section", (
f'<section id="slide-{number}" data-page="{page_id}"'
f' aria-label="第{number}页:{title}" aria-hidden="true"'
), 1)
text = text.replace("{{PAGE_NUMBER}}", f"{number:02}")
text = text.replace("{{PAGE_COUNT}}", str(len(ids)))
# asset: paths are relative to assets/, independent of the page's location.
text = re.sub(r"asset:([^\"'<>\s]+)", inline_asset, text)
pages.append(text.strip())
for css in page_styles:
styles.append(scoped_css(css, page_id))
for script in page_scripts:
scripts.append(
f'(() => {{\nconst root = document.querySelector(\'[data-page="{page_id}"]\');\n'
+ script + "\n})();"
)
shell = (ROOT / "src/shell.html").read_text()
for marker in ("{{SLIDES}}", "{{STYLES}}", "{{SCRIPTS}}"):
require(shell.count(marker) == 1, f"shell.html 中 {marker} 必须恰好出现一次")
result = shell.replace("{{PAGE_COUNT}}", str(len(ids)))
result = result.replace("{{SLIDES}}", "\n".join(pages))
result = result.replace("{{STYLES}}", "\n".join(styles))
result = result.replace("{{SCRIPTS}}", "\n".join(scripts).replace("</script", r"<\/script"))
require(not re.search(r"\{\{[A-Z_]+\}\}", result), "存在未替换的构建占位符")
parsed = Markup(result)
duplicates = [key for key, count in Counter(parsed.ids).items() if count > 1]
require(not duplicates, "存在重复 HTML ID: " + ", ".join(duplicates))
require(len(parsed.sections) == len(ids), "最终页面数不一致")
require(all(url.startswith("data:") for url in parsed.resources),
"播放资源必须内嵌,不能依赖网络或本地相对路径")
return result, material, len(ids)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="仅验证源码,不写入输出")
parser.add_argument("--output", type=Path, default=OUTPUT, help="指定生成文件位置")
args = parser.parse_args()
result, material, count = build()
if args.check:
print(f"检查通过:{count} 页;ID、页面元数据和内嵌资源有效。")
return
args.output.parent.mkdir(parents=True, exist_ok=True)
# Replace only after the complete deck has passed validation.
temp = args.output.with_suffix(".html.tmp")
temp.write_text(result, encoding="utf-8")
temp.replace(args.output)
MATERIAL_OUTPUT.parent.mkdir(parents=True, exist_ok=True)
MATERIAL_OUTPUT.write_bytes(material)
print(f"已生成 {count} 页:{args.output}")
if __name__ == "__main__":
main()