""" site_manager.py --------------- 網站內容管理工具。 python site_manager.py # 互動選單 python site_manager.py list # 列出所有 task python site_manager.py add [name] # 新增單元 task python site_manager.py delete [name] # 刪除單元 task python site_manager.py scan # 掃描 pages/ 補齊 paths.json python site_manager.py gen [all|unit|chapter|navbar] # 生成頁面 python site_manager.py gen --force # 強制覆蓋 python site_manager.py gen --dry-run # 預覽不寫入 """ from __future__ import annotations import json, re, argparse from pathlib import Path CONFIG_PATH = Path("paths.json") PAGES_ROOT = Path("pages") # ───────────────────────────────────────────────────────────────────────────── # 範本字串(用變數儲存,避免縮排問題) # ───────────────────────────────────────────────────────────────────────────── def _unit_template(json_filename: str, route: str, title: str) -> str: return ( "import dash\n" "from layout.page_builder import make_test_page\n" "from pathlib import Path\n" "\n" "HERE = Path(__file__).resolve().parent\n" f'JSON_PATH = HERE / "{json_filename}"\n' "\n" f'dash.register_page(__name__, path="/{route}", title="{title}")\n' "\n" "def layout():\n" " return make_test_page(str(JSON_PATH), top_offset=90)\n" ) def _chapter_template(path: str, title: str, cards_repr: str) -> str: return ( "from dash import register_page\n" "import dash_mantine_components as dmc\n" "from layout.Chapter_card import create_card\n" "\n" f'register_page(__name__, path="{path}", title="{title}")\n' "\n" f"card_data = {cards_repr}\n" "\n" "layout = dmc.SimpleGrid(\n" " cols=2,\n" ' spacing="sm",\n' " children=[create_card(data) for data in card_data],\n" ")\n" ) def _navbar_template(chapter_links: str) -> str: return ( "import dash\n" "from dash import html\n" "import dash_mantine_components as dmc\n" "from dash_iconify import DashIconify\n" "\n" "# 此檔案由 site_manager.py 自動生成,請勿手動修改\n" "\n" "def generate_navbar():\n" " height = 16\n" " navbar_content = html.Div(\n" " [\n" " dmc.NavLink(\n" ' label="首頁",\n' ' href="/",\n' ' leftSection=DashIconify(icon="bi:house-door-fill", height=height),\n' ' active="partial",\n' " ),\n" f"{chapter_links}\n" " ],\n" " style={\n" ' "width": {"xs": 200, "sm": 250, "md": 300, "lg": 300},\n' ' "overflowY": "auto",\n' ' "maxHeight": "100vh",\n' " },\n" " )\n" " return dmc.AppShellNavbar(\n" ' id="navbar",\n' " children=[\n" ' dmc.Divider(label="頁面導覽", variant="solid", labelPosition="left", size="md"),\n' " navbar_content,\n" " ],\n" ' p="md",\n' " )\n" ) def _chapter_nav_block(nav_label: str, nav_icon: str, section_links: str) -> str: return ( " dmc.NavLink(\n" f' label="{nav_label}",\n' f' leftSection=DashIconify(icon="{nav_icon}", height=height),\n' " childrenOffset=28,\n" ' active="partial",\n' " children=[\n" f"{section_links}\n" " ],\n" " )," ) def _section_link(label: str, href: str, icon: str) -> str: return ( " dmc.NavLink(\n" f' label="{label}",\n' f' href="{href}",\n' ' active="exact",\n' " childrenOffset=14,\n" f' leftSection=DashIconify(icon="{icon}", height=height),\n' " )," ) # ───────────────────────────────────────────────────────────────────────────── # paths.json 讀寫 # ───────────────────────────────────────────────────────────────────────────── def _load() -> dict: if CONFIG_PATH.exists(): return json.loads(CONFIG_PATH.read_text(encoding="utf-8")) return {"chapters": [], "tasks": [], "up_to_date": True} def _save(cfg: dict) -> None: CONFIG_PATH.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8") print(f"✅ 已儲存 {CONFIG_PATH}") def _task_names(cfg: dict) -> set[str]: return {t["name"] for t in cfg.get("tasks", [])} def _infer_paths(name: str) -> tuple[str, str]: m = re.match(r'[Cc][Hh]?(\d+)[_\-](\d+)', name) if m: ch, sec = m.group(1), m.group(2) folder = PAGES_ROOT / f"Ch{ch}" / f"C{ch}_{sec}" return str(folder / f"Md_{ch}_{sec}.md"), str(folder / f"exported_result_{ch}_{sec}.json") return "", "" # ───────────────────────────────────────────────────────────────────────────── # 卡片轉 Python 原始碼(badges 用 tuple) # ───────────────────────────────────────────────────────────────────────────── def _cards_to_repr(cards: list[dict]) -> str: lines = ["[\n"] for card in cards: lines.append(" {\n") lines.append(f' "title": {json.dumps(card["title"], ensure_ascii=False)},\n') badges = card.get("badges", []) badge_strs = [f'("{b[0]}", "{b[1]}")' for b in badges] lines.append(f' "badges": [{", ".join(badge_strs)}],\n') desc = card.get("description", []) desc_strs = ", ".join(json.dumps(d, ensure_ascii=False) for d in desc) lines.append(f' "description": [{desc_strs}],\n') lines.append(f' "link": {json.dumps(card.get("link", ""), ensure_ascii=False)},\n') lines.append(f' "test": {json.dumps(card.get("test", ""), ensure_ascii=False)},\n') if "trick" in card: lines.append(f' "trick": {json.dumps(card["trick"], ensure_ascii=False)},\n') lines.append(" },\n") lines.append("]") return "".join(lines) # ───────────────────────────────────────────────────────────────────────────── # 命令:list # ───────────────────────────────────────────────────────────────────────────── def cmd_list() -> None: cfg = _load() chs = cfg.get("chapters", []) tasks = cfg.get("tasks", []) print(f"\n章節總覽({len(chs)} 個):") for ch in chs: print(f" • {ch['name']:<10} {ch.get('title','')} ({len(ch.get('cards',[]))} 張卡片)") print(f"\n單元 task({len(tasks)} 個):") for i, t in enumerate(tasks, 1): md_ok = "✔" if Path(t.get("md", "")).exists() else "✘" out_ok = "✔" if Path(t.get("out", "")).exists() else "✘" print(f" {i:2d}. {t['name']:<12} md:{md_ok} json:{out_ok} → {t.get('out','')}") # ───────────────────────────────────────────────────────────────────────────── # 命令:add # ───────────────────────────────────────────────────────────────────────────── def cmd_add(name: str | None = None) -> None: cfg = _load() if not name: name = input("Task 名稱(例如 Ch14_3):").strip() if not name: print("❌ 名稱不能為空。"); return if name in _task_names(cfg): print(f"❌ '{name}' 已存在。"); return default_md, default_out = _infer_paths(name) print(f"預設 md 路徑:{default_md or '(請自行輸入)'}") md = input("md 路徑(Enter 使用預設):").strip() or default_md print(f"預設 out 路徑:{default_out or '(請自行輸入)'}") out = input("out 路徑(Enter 使用預設):").strip() or default_out if not md or not out: print("❌ 路徑不能為空。"); return md_path = Path(md) md_path.parent.mkdir(parents=True, exist_ok=True) Path(out).parent.mkdir(parents=True, exist_ok=True) if not md_path.exists(): md_path.write_text(f"# {name}\n\n在這裡撰寫教學內容。\n", encoding="utf-8") print(f" 📄 已建立空白 MD:{md_path}") cfg["tasks"].append({"name": name, "md": md, "out": out}) cfg["up_to_date"] = False # 自動加進對應 chapter m = re.match(r'[Cc][Hh]?(\d+)[_\-](\d+)', name) if m: ch_num = m.group(1) sec_num = m.group(2) ch_key = f"Ch{ch_num}" route = f"/{name}" chapter = next((c for c in cfg.get("chapters", []) if c.get("name") == ch_key), None) if chapter is None: ans = input(f"\n找不到 {ch_key} 的章節總覽,要建立嗎?(y/n):").strip().lower() if ans == "y": title = input("章節標題:").strip() or ch_key chapter = {"name": ch_key, "path": f"/{ch_key}", "title": title, "folder": f"pages/{ch_key}", "cards": []} cfg.setdefault("chapters", []).append(chapter) print(f" 📁 已建立 chapter:{ch_key}") if chapter is not None: existing = {c.get("link") for c in chapter.get("cards", [])} if route not in existing: card_title = input("卡片標題(Enter 略過):").strip() if card_title: desc_raw = input("卡片說明(多行用 | 分隔,Enter 略過):").strip() desc = [d.strip() for d in desc_raw.split("|") if d.strip()] chapter.setdefault("cards", []).append({ "title": card_title, "badges": [[ch_key, "pink"]], "description": desc, "link": route, "test": f"{route}/test/" }) print(f" 🃏 已將卡片加入 {ch_key} 總覽") _save(cfg) print(f"✅ 已新增 task:{name}\n 下一步:編輯 {md_path},然後執行 python build.py") # ───────────────────────────────────────────────────────────────────────────── # 命令:delete # ───────────────────────────────────────────────────────────────────────────── def cmd_delete(name: str | None = None) -> None: cfg = _load() if not name: cmd_list() name = input("\n要刪除的 task 名稱:").strip() before = len(cfg["tasks"]) cfg["tasks"] = [t for t in cfg["tasks"] if t["name"] != name] if len(cfg["tasks"]) == before: print(f"❌ 找不到 '{name}'。"); return _save(cfg) print(f"✅ 已刪除 task:{name}(MD/JSON 檔案需手動刪除)") # ───────────────────────────────────────────────────────────────────────────── # 命令:scan # ───────────────────────────────────────────────────────────────────────────── def cmd_scan() -> None: cfg, existing, added = _load(), _task_names(_load()), [] for md_file in sorted(PAGES_ROOT.rglob("Md_*.md")): parts = md_file.parts if len(parts) < 3: continue m = re.match(r'C(\d+)_(\d+)', parts[-2]) if not m: continue ch, sec = m.group(1), m.group(2) name = f"Ch{ch}_{sec}" if name in existing: print(f" — {name}:已存在"); continue out = md_file.parent / f"exported_result_{ch}_{sec}.json" cfg["tasks"].append({"name": name, "md": str(md_file), "out": str(out)}) existing.add(name); added.append(name) print(f" ✔ 新增:{name}") if added: cfg["up_to_date"] = False _save(cfg) print(f"\n共新增 {len(added)} 個 task。執行 python build.py 轉換 MD。") else: print("沒有新的 MD 需要補充。") # ───────────────────────────────────────────────────────────────────────────── # 命令:gen(生成單元頁 + 總覽頁) # ───────────────────────────────────────────────────────────────────────────── def cmd_gen(target: str = "all", force: bool = False, dry_run: bool = False) -> None: cfg = _load() base = CONFIG_PATH.resolve().parent generated, skipped = [], [] # 單元頁 if target in ("all", "unit"): for t in cfg.get("tasks", []): name = t.get("name", "") out_path = Path(t.get("out", "")) if not name or not out_path.name: continue folder = (base / out_path).resolve().parent page_file = folder / f"Dash_{name}.py" if page_file.exists() and not force: skipped.append(f" — [單元] {page_file}"); continue content = _unit_template( json_filename=(base / out_path).resolve().name, route=name, title=name, ) if not dry_run: folder.mkdir(parents=True, exist_ok=True) page_file.write_text(content, encoding="utf-8") generated.append(f" ✔ [單元] {page_file}") # 總覽頁 if target in ("all", "chapter"): for ch in cfg.get("chapters", []): name = ch.get("name", "") if not name: continue folder = Path(ch.get("folder", f"pages/{name}")) page_file = folder / f"{name.lower()}.py" if page_file.exists() and not force: skipped.append(f" — [總覽] {page_file}"); continue content = _chapter_template( path = ch.get("path", f"/{name}"), title = ch.get("title", name), cards_repr = _cards_to_repr(ch.get("cards", [])), ) if not dry_run: folder.mkdir(parents=True, exist_ok=True) page_file.write_text(content, encoding="utf-8") generated.append(f" ✔ [總覽] {page_file}") label = "[dry-run] 會生成" if dry_run else "已生成" if generated: print(f"\n✅ {label} {len(generated)} 個檔案:") print("\n".join(generated)) if skipped: print(f"\n⏭ 已略過 {len(skipped)} 個(用 --force 覆蓋):") for s in skipped: print(s) if not generated and not skipped: print("沒有任何檔案需要生成。") # ───────────────────────────────────────────────────────────────────────────── # 命令:gen navbar # ───────────────────────────────────────────────────────────────────────────── def cmd_gen_navbar(force: bool = False, dry_run: bool = False) -> None: cfg = _load() chapters = cfg.get("chapters", []) nav_file = Path("layout/shell/navbar.py") if not chapters: print("paths.json 裡沒有 chapters,無法生成 navbar。"); return if nav_file.exists() and not force: print(f"⏭ {nav_file} 已存在,用 --force 覆蓋。"); return chapter_blocks = [] for ch in chapters: nav_label = ch.get("nav_label") or ch.get("title") or ch.get("name", "") nav_icon = ch.get("nav_icon", "mdi:book-outline") sections = ch.get("sections", []) if not sections: ch_name = ch.get("name", "") m = re.match(r'[Cc][Hh]?(\d+)', ch_name) ch_num = m.group(1) if m else "" sections = [{"label": f"{ch_name} 總覽", "href": f"/{ch_name}", "icon": "tabler:square-rounded-number-0"}] icons = ["one","two","three","four","five","six","seven","eight","nine"] for t in cfg.get("tasks", []): tn = t.get("name", "") tm = re.match(rf'[Cc][Hh]?{ch_num}[_\-](\d+)', tn) if ch_num else None if tm: sec = int(tm.group(1)) icon_name = icons[sec-1] if sec <= len(icons) else str(sec) sections.append({"label": tn, "href": f"/{tn}", "icon": f"tabler:square-rounded-number-{icon_name}"}) links = "\n".join( _section_link(s.get("label",""), s.get("href","/"), s.get("icon","tabler:circle")) for s in sections ) chapter_blocks.append(_chapter_nav_block(nav_label, nav_icon, links)) content = _navbar_template("\n".join(chapter_blocks)) if dry_run: print(f"[dry-run] 會生成:{nav_file}\n") print(content); return nav_file.parent.mkdir(parents=True, exist_ok=True) nav_file.write_text(content, encoding="utf-8") print(f"✅ 已生成:{nav_file}") # ───────────────────────────────────────────────────────────────────────────── # 互動選單 # ───────────────────────────────────────────────────────────────────────────── def menu() -> None: while True: print("\n═══════════ 網站管理工具 ═══════════") print(" 1. 列出所有 task / chapter") print(" 2. 新增單元 task") print(" 3. 刪除單元 task") print(" 4. 掃描 pages/ 補齊 paths.json") print(" ─────────── 生成頁面 ───────────") print(" 5. 生成全部頁面(單元 + 總覽)") print(" 6. 強制重新生成全部") print(" 7. 只生成小單元頁") print(" 8. 強制重新生成小單元頁") print(" 9. 只生成總覽頁") print(" 10. 強制重新生成總覽頁") print(" 11. 生成 navbar.py") print(" 12. 強制重新生成 navbar.py") print(" 0. 離開") c = input("\n請選擇:").strip() if c == "1": cmd_list() elif c == "2": cmd_add() elif c == "3": cmd_delete() elif c == "4": cmd_scan() elif c == "5": cmd_gen("all") elif c == "6": cmd_gen("all", force=True) elif c == "7": cmd_gen("unit") elif c == "8": cmd_gen("unit", force=True) elif c == "9": cmd_gen("chapter") elif c == "10": cmd_gen("chapter", force=True) elif c == "11": cmd_gen_navbar() elif c == "12": cmd_gen_navbar(force=True) elif c == "0": print("bye!"); break else: print("請輸入 0-12。") # ───────────────────────────────────────────────────────────────────────────── # 命令列介面 # ───────────────────────────────────────────────────────────────────────────── if __name__ == "__main__": ap = argparse.ArgumentParser(description="網站內容管理工具") sub = ap.add_subparsers(dest="cmd") sub.add_parser("list", help="列出所有 task / chapter") sub.add_parser("scan", help="掃描 pages/ 補齊 paths.json") p_add = sub.add_parser("add", help="新增單元 task") p_add.add_argument("name", nargs="?") p_del = sub.add_parser("delete", help="刪除單元 task") p_del.add_argument("name", nargs="?") p_gen = sub.add_parser("gen", help="生成頁面檔") p_gen.add_argument("target", nargs="?", default="all", choices=["all","unit","chapter","navbar"], help="all=全部, unit=小單元, chapter=總覽, navbar=導覽列") p_gen.add_argument("--force", action="store_true") p_gen.add_argument("--dry-run", action="store_true") args = ap.parse_args() if args.cmd == "list": cmd_list() elif args.cmd == "add": cmd_add(args.name) elif args.cmd == "delete": cmd_delete(args.name) elif args.cmd == "scan": cmd_scan() elif args.cmd == "gen": if args.target == "navbar": cmd_gen_navbar(args.force, args.dry_run) else: cmd_gen(args.target, args.force, args.dry_run) else: menu()