feat(webpage): add deploy, pdf subcommands and build --serve option

Migrate mywebpage/bin/mywebpage CLI features into mytoolkit:
- deploy: auto-infer GIT_USER from gh CLI or git origin
- pdf build/open: delegate to project's scripts/export-docs-pdf.py
- build --serve: serve built site after successful build
This commit is contained in:
Zhengshou Lai
2026-05-05 09:15:19 +08:00
parent c75cba9fee
commit f2722176eb
+178 -8
View File
@@ -1,11 +1,14 @@
"""Webpage project utilities (Docusaurus build / clear / etc.). """Webpage project utilities (Docusaurus build / clear / deploy / pdf / etc.).
Project metadata lives in `server.py` so that adding a new webpage there Project metadata lives in `server.py` so that adding a new webpage there
automatically wires it up here. automatically wires it up here.
""" """
import re
import shutil
import subprocess import subprocess
import time import time
from pathlib import Path
import click import click
@@ -67,13 +70,6 @@ def webpage_list():
click.echo("") click.echo("")
@webpage_cmd.command("build")
@click.argument("name", type=click.Choice(_NAMES, case_sensitive=False))
def webpage_build(name):
"""Build a webpage project (`npm run build`)."""
_run_npm(name, "build", "Building")
@webpage_cmd.command("clear") @webpage_cmd.command("clear")
@click.argument("name", type=click.Choice(_NAMES, case_sensitive=False)) @click.argument("name", type=click.Choice(_NAMES, case_sensitive=False))
def webpage_clear(name): def webpage_clear(name):
@@ -87,3 +83,177 @@ def webpage_rebuild(name):
"""Clear cache then build (`npm run clear && npm run build`).""" """Clear cache then build (`npm run clear && npm run build`)."""
_run_npm(name, "clear", "Clearing") _run_npm(name, "clear", "Clearing")
_run_npm(name, "build", "Building") _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("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)