167 lines
7.6 KiB
Python
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()
|