`mytoolkit webpage <project> server <action>` mirrors `mytoolkit server <action> <project>`, so each Docusaurus project can manage its own dev/serve lifecycle from the webpage namespace.
362 lines
13 KiB
Python
362 lines
13 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.
|
|
|
|
Command layout: `mytoolkit webpage <project> <action> [options]`
|
|
e.g. `mytoolkit webpage mywebpage build --serve`.
|
|
"""
|
|
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
|
|
import click
|
|
|
|
from bin.commands.server import (
|
|
manageable_services,
|
|
get_service,
|
|
no_color_env,
|
|
server_start,
|
|
server_stop,
|
|
server_restart,
|
|
server_status,
|
|
)
|
|
|
|
|
|
_PROJECTS = manageable_services()
|
|
|
|
|
|
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 # _PROJECTS 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")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Action implementations (per-project, called from project subgroup commands)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _build_serve_impl(name: str, serve: bool, port: int | None) -> None:
|
|
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()
|
|
|
|
|
|
def _push_impl(name: str, force: bool, push_args: tuple[str, ...]) -> None:
|
|
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")
|
|
|
|
|
|
def _deploy_impl(name: str, deploy_args: tuple[str, ...]) -> None:
|
|
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()
|
|
|
|
if not env.get("GIT_USER"):
|
|
use_ssh = env.get("USE_SSH", "").lower() == "true"
|
|
if not use_ssh:
|
|
login = ""
|
|
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
|
|
|
|
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")
|
|
|
|
|
|
def _pdf_build_impl(name, output, title, subtitle, date, lang) -> None:
|
|
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")
|
|
|
|
|
|
def _pdf_open_impl(name, output) -> None:
|
|
svc = get_service(name)
|
|
assert svc.project_dir is not None
|
|
|
|
if output:
|
|
pdf_path = svc.project_dir / output
|
|
else:
|
|
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(f"Run 'mytoolkit webpage {svc.name} pdf build' first.", fg="yellow")
|
|
raise click.Abort()
|
|
|
|
click.secho(f"Opening {pdf_path} ...", fg="cyan")
|
|
subprocess.run(["open", str(pdf_path)], check=False)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Top-level group + project subgroup factory
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@click.group(name="webpage")
|
|
def webpage_cmd():
|
|
"""Manage Docusaurus webpage projects.
|
|
|
|
Usage: `mytoolkit webpage <project> <action> [options]`
|
|
"""
|
|
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
|
|
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("")
|
|
|
|
|
|
def _make_project_group(name: str, description: str) -> click.Group:
|
|
"""Build a click subgroup for a single webpage project."""
|
|
|
|
@click.group(name=name, help=f"{description}.")
|
|
def project_group():
|
|
pass
|
|
|
|
@project_group.command("clear")
|
|
def clear_cmd():
|
|
"""Clear Docusaurus build artifacts and cache (`npm run clear`)."""
|
|
_run_npm(name, "clear", "Clearing")
|
|
|
|
@project_group.command("rebuild")
|
|
def rebuild_cmd():
|
|
"""Clear cache then build (`npm run clear && npm run build`)."""
|
|
_run_npm(name, "clear", "Clearing")
|
|
_run_npm(name, "build", "Building")
|
|
|
|
@project_group.command("build")
|
|
@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 build_cmd(serve, port):
|
|
"""Build the project (`npm run build`). Use --serve to start serving."""
|
|
_build_serve_impl(name, serve, port)
|
|
|
|
@project_group.command("push", context_settings={"ignore_unknown_options": True})
|
|
@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, type=click.UNPROCESSED)
|
|
def push_cmd(force, push_args):
|
|
"""Push the 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`).
|
|
"""
|
|
_push_impl(name, force, push_args)
|
|
|
|
@project_group.command("deploy", context_settings={"ignore_unknown_options": True})
|
|
@click.argument("deploy_args", nargs=-1, type=click.UNPROCESSED)
|
|
def deploy_cmd(deploy_args):
|
|
"""Deploy to GitHub Pages (`npm run deploy`).
|
|
|
|
Automatically infers GIT_USER from `gh` CLI or git origin URL.
|
|
"""
|
|
_deploy_impl(name, deploy_args)
|
|
|
|
@project_group.group(name="pdf")
|
|
def pdf_group():
|
|
"""Export docs to PDF or open existing PDF."""
|
|
pass
|
|
|
|
@pdf_group.command("build")
|
|
@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_cmd(output, title, subtitle, date, lang):
|
|
"""Export docs to PDF via project's scripts/export-docs-pdf.py."""
|
|
_pdf_build_impl(name, output, title, subtitle, date, lang)
|
|
|
|
@pdf_group.command("open")
|
|
@click.option("-o", "--output", help="PDF path to open")
|
|
def pdf_open_cmd(output):
|
|
"""Open the exported PDF with the default application."""
|
|
_pdf_open_impl(name, output)
|
|
|
|
@project_group.group(name="server")
|
|
def server_group():
|
|
"""Manage this project's local dev/serve server (delegates to `mytoolkit server`)."""
|
|
pass
|
|
|
|
@server_group.command("start")
|
|
@click.option("--dev", is_flag=True, help="Start hot-reload dev server on the dev port (default: serve)")
|
|
@click.option("--rebuild", is_flag=True, help="Force npm run build before serve (no effect with --dev)")
|
|
def server_start_cmd(dev, rebuild):
|
|
"""Start the project server in the background."""
|
|
click.get_current_context().invoke(server_start, name=name, dev=dev, rebuild=rebuild)
|
|
|
|
@server_group.command("stop")
|
|
@click.option("--dev", is_flag=True, help="Stop the dev-port instance instead of serve")
|
|
@click.option("--all", "all_modes", is_flag=True, help="Stop both dev and serve instances")
|
|
def server_stop_cmd(dev, all_modes):
|
|
"""Stop the project server."""
|
|
click.get_current_context().invoke(server_stop, name=name, dev=dev, all_modes=all_modes)
|
|
|
|
@server_group.command("restart")
|
|
@click.option("--dev", is_flag=True, help="Restart the dev instance (default: serve)")
|
|
@click.option("--rebuild", is_flag=True, help="Force npm run build before serve (no effect with --dev)")
|
|
def server_restart_cmd(dev, rebuild):
|
|
"""Restart the project server."""
|
|
click.get_current_context().invoke(server_restart, name=name, dev=dev, rebuild=rebuild)
|
|
|
|
@server_group.command("status")
|
|
def server_status_cmd():
|
|
"""Show detailed server status (serve and dev ports)."""
|
|
click.get_current_context().invoke(server_status, name=name)
|
|
|
|
return project_group
|
|
|
|
|
|
# Register one subgroup per managed project. Each project becomes
|
|
# `mytoolkit webpage <project>` with its own action subcommands.
|
|
for _svc in _PROJECTS:
|
|
webpage_cmd.add_command(_make_project_group(_svc.name, _svc.description))
|