Files
mytoolkit/bin/commands/webpage.py
T
Zhengshou Lai 92f8536604 feat(webpage): add push subcommand with -f flag
新增 `mytoolkit webpage push <name>` 用于推送项目源码仓库,
支持 `-f/--force` 选项(内部使用 --force-with-lease)。
2026-05-05 12:40:10 +08:00

296 lines
10 KiB
Python

"""Webpage project utilities (Docusaurus build / clear / deploy / pdf / etc.).
Project metadata lives in `server.py` so that adding a new webpage there
automatically wires it up here.
"""
import re
import shutil
import subprocess
import time
from pathlib import Path
import click
from bin.commands.server import manageable_services, get_service, no_color_env
_PROJECTS = manageable_services()
_NAMES = [s.name for s in _PROJECTS]
def _run_npm(name: str, script: str, label: str) -> None:
"""Run `npm run <script>` in the project's directory, streaming output."""
svc = get_service(name)
assert svc.project_dir is not None # _NAMES is manageable services only
if not svc.project_dir.exists():
click.secho(f"Project directory not found: {svc.project_dir}", fg="red")
raise click.Abort()
click.secho(f"{label} {svc.name} ({svc.description}) ...", fg="cyan")
started = time.time()
proc = subprocess.run(["npm", "run", script], cwd=svc.project_dir, env=no_color_env())
elapsed = time.time() - started
if proc.returncode != 0:
click.secho(f"{script} failed for {svc.name} (exit {proc.returncode})", fg="red")
raise click.Abort()
click.secho(f"{svc.name} {script} done in {elapsed:.1f}s", fg="green")
@click.group(name="webpage")
def webpage_cmd():
"""Manage Docusaurus webpage projects."""
pass
@webpage_cmd.command("list")
def webpage_list():
"""List managed webpage projects and their build status."""
click.echo("")
click.secho(
f"{'Name':<16} {'Build':<12} {'Description':<36} {'Project'}",
fg="bright_blue",
bold=True,
)
click.echo("-" * 100)
for svc in _PROJECTS:
assert svc.project_dir is not None # _PROJECTS is manageable services only
build_dir = svc.project_dir / "build"
if build_dir.exists():
build_status = time.strftime("%m-%d %H:%M", time.localtime(build_dir.stat().st_mtime))
color = "green"
else:
build_status = "missing"
color = "yellow"
click.secho(
f"{svc.name:<16} {build_status:<12} {svc.description:<36} {svc.project_dir}",
fg=color,
)
click.echo("")
@webpage_cmd.command("clear")
@click.argument("name", type=click.Choice(_NAMES, case_sensitive=False))
def webpage_clear(name):
"""Clear Docusaurus build artifacts and cache (`npm run clear`)."""
_run_npm(name, "clear", "Clearing")
@webpage_cmd.command("rebuild")
@click.argument("name", type=click.Choice(_NAMES, case_sensitive=False))
def webpage_rebuild(name):
"""Clear cache then build (`npm run clear && npm run build`)."""
_run_npm(name, "clear", "Clearing")
_run_npm(name, "build", "Building")
@webpage_cmd.command("build")
@click.argument("name", type=click.Choice(_NAMES, case_sensitive=False))
@click.option("--serve", "-s", is_flag=True, help="Serve the built site after successful build")
@click.option("--port", "-p", type=int, help="Port for serve (default: Docusaurus default)")
def webpage_build_serve(name, serve, port):
"""Build a webpage project (`npm run build`). Use --serve to start serving."""
svc = get_service(name)
assert svc.project_dir is not None
_run_npm(name, "build", "Building")
if serve:
click.secho(f"Serving {svc.name} ...", fg="cyan")
cmd = ["npx", "docusaurus", "serve", "--no-open"]
if port is not None:
cmd += ["--port", str(port)]
proc = subprocess.run(cmd, cwd=svc.project_dir, env=no_color_env())
if proc.returncode != 0:
click.secho(f"Serve failed for {svc.name}", fg="red")
raise click.Abort()
@webpage_cmd.command("push")
@click.argument("name", type=click.Choice(_NAMES, case_sensitive=False))
@click.option(
"-f",
"--force",
is_flag=True,
help="Force push via --force-with-lease (refuses if upstream moved unexpectedly).",
)
@click.argument("push_args", nargs=-1)
def webpage_push(name, force, push_args):
"""Push the webpage project's source repo (`git push`).
Default pushes the current branch to its tracked upstream. Extra
arguments are forwarded to `git push` (e.g. `origin main`).
"""
svc = get_service(name)
assert svc.project_dir is not None
if not svc.project_dir.exists():
click.secho(f"Project directory not found: {svc.project_dir}", fg="red")
raise click.Abort()
cmd = ["git", "push"]
if force:
cmd.append("--force-with-lease")
if push_args:
cmd.extend(push_args)
click.secho(f"Pushing {svc.name} ({svc.description}) ...", fg="cyan")
proc = subprocess.run(cmd, cwd=svc.project_dir, env=no_color_env())
if proc.returncode != 0:
click.secho(f"Push failed for {svc.name} (exit {proc.returncode})", fg="red")
raise click.Abort()
click.secho(f"{svc.name} pushed.", fg="green")
@webpage_cmd.command("deploy")
@click.argument("name", type=click.Choice(_NAMES, case_sensitive=False))
@click.argument("deploy_args", nargs=-1)
def webpage_deploy(name, deploy_args):
"""Deploy a webpage project to GitHub Pages (`npm run deploy`).
Automatically infers GIT_USER from `gh` CLI or git origin URL.
"""
svc = get_service(name)
assert svc.project_dir is not None
if not svc.project_dir.exists():
click.secho(f"Project directory not found: {svc.project_dir}", fg="red")
raise click.Abort()
env = no_color_env()
# Infer GIT_USER if not already set
if not env.get("GIT_USER"):
use_ssh = env.get("USE_SSH", "").lower() == "true"
if not use_ssh:
login = ""
# Try gh CLI
gh_path = shutil.which("gh")
if gh_path:
try:
result = subprocess.run(
[gh_path, "api", "user", "--jq", ".login"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
login = result.stdout.strip()
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
# Fallback to git origin URL
if not login:
try:
result = subprocess.run(
["git", "-C", str(svc.project_dir), "remote", "get-url", "origin"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
origin = result.stdout.strip()
m = re.search(r"github\.com[:/]([^/]+)/", origin)
if m:
login = m.group(1)
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
if login:
env["GIT_USER"] = login
click.secho(f"Inferred GIT_USER={login}", fg="bright_black")
click.secho(f"Deploying {svc.name} ({svc.description}) ...", fg="cyan")
cmd = ["npm", "run", "deploy"]
if deploy_args:
cmd.append("--")
cmd.extend(deploy_args)
proc = subprocess.run(cmd, cwd=svc.project_dir, env=env)
if proc.returncode != 0:
click.secho(f"Deploy failed for {svc.name}", fg="red")
raise click.Abort()
click.secho(f"{svc.name} deployed.", fg="green")
# ---------------------------------------------------------------------------
# PDF subcommand
# ---------------------------------------------------------------------------
@click.group(name="pdf", invoke_without_command=False)
def pdf_cmd():
"""Export docs to PDF or open existing PDF."""
pass
@pdf_cmd.command("build")
@click.argument("name", type=click.Choice(_NAMES, case_sensitive=False))
@click.option("-o", "--output", help="Output PDF path")
@click.option("-t", "--title", help="Cover page title")
@click.option("-s", "--subtitle", help="Cover page subtitle")
@click.option("-d", "--date", help="Cover page date")
@click.option("-l", "--lang", type=click.Choice(["zh", "en", "both"]), help="Language (mywebpage only)")
def pdf_build(name, output, title, subtitle, date, lang):
"""Export docs to PDF via project's scripts/export-docs-pdf.py."""
svc = get_service(name)
assert svc.project_dir is not None
script = svc.project_dir / "scripts" / "export-docs-pdf.py"
if not script.exists():
click.secho(f"PDF export script not found: {script}", fg="red")
click.secho("Expected: scripts/export-docs-pdf.py in the project directory.", fg="yellow")
raise click.Abort()
cmd = ["python3", str(script)]
if output:
cmd += ["--output", output]
if title:
cmd += ["--title", title]
if subtitle:
cmd += ["--subtitle", subtitle]
if date:
cmd += ["--date", date]
if lang:
cmd += ["--lang", lang]
click.secho(f"Exporting PDF for {svc.name} ...", fg="cyan")
proc = subprocess.run(cmd, cwd=svc.project_dir, env=no_color_env())
if proc.returncode != 0:
click.secho(f"PDF export failed for {svc.name}", fg="red")
raise click.Abort()
click.secho(f"{svc.name} PDF exported.", fg="green")
@pdf_cmd.command("open")
@click.argument("name", type=click.Choice(_NAMES, case_sensitive=False))
@click.option("-o", "--output", help="PDF path to open")
def pdf_open(name, output):
"""Open the exported PDF with the default application."""
svc = get_service(name)
assert svc.project_dir is not None
if output:
pdf_path = svc.project_dir / output
else:
# Try to infer default path from the export script's default
script = svc.project_dir / "scripts" / "export-docs-pdf.py"
pdf_path = None
if script.exists():
content = script.read_text(encoding="utf-8")
m = re.search(r'(?:DEFAULT_OUTPUT|default)\s*=\s*["\']([^"\']+)["\']', content)
if m:
pdf_path = svc.project_dir / m.group(1)
if pdf_path is None:
pdf_path = svc.project_dir / "static" / "pdf" / f"{svc.name}-docs.pdf"
if not pdf_path.exists():
click.secho(f"PDF not found: {pdf_path}", fg="red")
click.secho("Run 'mytoolkit webpage pdf build <name>' first.", fg="yellow")
raise click.Abort()
click.secho(f"Opening {pdf_path} ...", fg="cyan")
subprocess.run(["open", str(pdf_path)], check=False)
webpage_cmd.add_command(pdf_cmd)