#!/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}>(.*?)", text, re.S) return re.sub(rf"<{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("