feat: 添加知识库培训离线网页及互动练习
This commit is contained in:
1 parent
cdf677cab7
commit
f1e45178d2
43 files changed
+1513
-166
No files matched your search
@@ -0,0 +1,164 @@
|
||||
#!/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 / "outputs/index.html"
|
||||
|
||||
|
||||
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)
|
||||
(args.output.parent / "培训练习材料.zip").write_bytes(material)
|
||||
print(f"已生成 {count} 页:{args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user