pugflow 2026.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pugflow/__init__.py +7 -0
- pugflow/__main__.py +5 -0
- pugflow/cli.py +59 -0
- pugflow/render.py +71 -0
- pugflow/server.py +133 -0
- pugflow/web/app.mjs +2728 -0
- pugflow/web/docs.css +23 -0
- pugflow/web/docs.html +164 -0
- pugflow/web/editor-source.mjs +649 -0
- pugflow/web/index.html +188 -0
- pugflow/web/layout.mjs +449 -0
- pugflow/web/math-render.mjs +134 -0
- pugflow/web/parser.mjs +926 -0
- pugflow/web/pugflow.css +32 -0
- pugflow/web/pugflow.mjs +1169 -0
- pugflow/web/render-cli.mjs +29 -0
- pugflow/web/render.html +1 -0
- pugflow/web/reusable-style.mjs +53 -0
- pugflow/web/style-sheet.mjs +62 -0
- pugflow/web/styles.css +576 -0
- pugflow/web/text-editor.mjs +135 -0
- pugflow/web/vendor/mathjax/LICENSE +202 -0
- pugflow/web/vendor/mathjax/tex-svg-full.js +34 -0
- pugflow/web/vim-mode.mjs +367 -0
- pugflow-2026.1.dist-info/METADATA +510 -0
- pugflow-2026.1.dist-info/RECORD +29 -0
- pugflow-2026.1.dist-info/WHEEL +5 -0
- pugflow-2026.1.dist-info/entry_points.txt +2 -0
- pugflow-2026.1.dist-info/top_level.txt +1 -0
pugflow/__init__.py
ADDED
pugflow/__main__.py
ADDED
pugflow/cli.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Command-line interface for Pugflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .server import serve
|
|
10
|
+
from .render import render_png
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_parser():
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="pugflow",
|
|
16
|
+
description="Open the editor or render a Pug diagram to PNG.",
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument("input", nargs="?", type=Path, help="Pug source to render. Omit to open the editor.")
|
|
19
|
+
parser.add_argument("-o", "--output", type=Path, help="PNG output path (default: input filename with .png).")
|
|
20
|
+
parser.add_argument("--css", type=Path, help="External reusable-style CSS file.")
|
|
21
|
+
parser.add_argument("--scale", type=float, default=2, help="PNG scale multiplier (default: 2).")
|
|
22
|
+
parser.add_argument("--timeout", type=float, default=30, help="Headless rendering timeout in seconds (default: 30).")
|
|
23
|
+
parser.add_argument("--host", default="127.0.0.1", help="Interface to bind (default: 127.0.0.1).")
|
|
24
|
+
parser.add_argument("--port", default=4173, type=int, help="Port to bind; use 0 for an available port (default: 4173).")
|
|
25
|
+
parser.add_argument("--no-browser", action="store_true", help="Do not open the application in the default browser.")
|
|
26
|
+
parser.add_argument("--vim", action="store_true", help="Open the GUI with Vim editing mode enabled.")
|
|
27
|
+
parser.add_argument("--gui", action="store_true", help="Open the input Pug and optional CSS in the GUI instead of rendering.")
|
|
28
|
+
parser.add_argument("--demo", action="store_true", help="Open the GUI with the full feature-tour example.")
|
|
29
|
+
parser.add_argument("--quiet", action="store_true", help="Suppress HTTP request logging.")
|
|
30
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
31
|
+
return parser
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main(argv=None):
|
|
35
|
+
args = build_parser().parse_args(argv)
|
|
36
|
+
if args.input and not args.gui:
|
|
37
|
+
if args.scale <= 0:
|
|
38
|
+
raise SystemExit("--scale must be greater than zero")
|
|
39
|
+
output = args.output or args.input.with_suffix(".png")
|
|
40
|
+
try:
|
|
41
|
+
render_png(args.input, output, css_path=args.css, scale=args.scale, timeout=args.timeout)
|
|
42
|
+
except (OSError, RuntimeError) as error:
|
|
43
|
+
raise SystemExit(f"error: {error}") from error
|
|
44
|
+
print(f"Wrote {output}")
|
|
45
|
+
return
|
|
46
|
+
if args.output:
|
|
47
|
+
raise SystemExit("--output requires rendering an input .pug file")
|
|
48
|
+
if args.css and not args.input:
|
|
49
|
+
raise SystemExit("--css requires an input .pug file")
|
|
50
|
+
css_path = args.css
|
|
51
|
+
if args.input and css_path is None:
|
|
52
|
+
candidate = args.input.with_suffix(".css")
|
|
53
|
+
css_path = candidate if candidate.is_file() else None
|
|
54
|
+
serve(args.host, args.port, open_browser=not args.no_browser, quiet=args.quiet, vim=args.vim,
|
|
55
|
+
demo=args.demo, pug_path=args.input, css_path=css_path)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
main()
|
pugflow/render.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Headless browser-backed PNG rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
import threading
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .server import create_server
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def find_browser() -> str:
|
|
16
|
+
configured = os.environ.get("PUGFLOW_BROWSER")
|
|
17
|
+
candidates = [configured] if configured else []
|
|
18
|
+
candidates += [shutil.which(name) for name in ("msedge", "chrome", "chromium", "chromium-browser")]
|
|
19
|
+
if os.name == "nt":
|
|
20
|
+
candidates += [
|
|
21
|
+
str(Path(os.environ.get("PROGRAMFILES(X86)", "C:/Program Files (x86)")) / "Microsoft/Edge/Application/msedge.exe"),
|
|
22
|
+
str(Path(os.environ.get("PROGRAMFILES", "C:/Program Files")) / "Google/Chrome/Application/chrome.exe"),
|
|
23
|
+
]
|
|
24
|
+
for candidate in candidates:
|
|
25
|
+
if candidate and Path(candidate).is_file():
|
|
26
|
+
return candidate
|
|
27
|
+
raise RuntimeError("No Chromium browser found. Install Edge/Chrome/Chromium or set PUGFLOW_BROWSER.")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def render_png(source_path: Path, output_path: Path, *, css_path: Path | None = None, scale: float = 2, timeout: float = 30) -> None:
|
|
31
|
+
source_path = source_path.resolve()
|
|
32
|
+
output_path = output_path.resolve()
|
|
33
|
+
css_path = css_path.resolve() if css_path else None
|
|
34
|
+
if not source_path.is_file():
|
|
35
|
+
raise FileNotFoundError(f"Diagram source not found: {source_path}")
|
|
36
|
+
if css_path and not css_path.is_file():
|
|
37
|
+
raise FileNotFoundError(f"Stylesheet not found: {css_path}")
|
|
38
|
+
server = create_server("127.0.0.1", 0, quiet=True)
|
|
39
|
+
server.render_source = source_path.read_text(encoding="utf-8")
|
|
40
|
+
server.render_styles = css_path.read_text(encoding="utf-8") if css_path else ""
|
|
41
|
+
server.render_result = None
|
|
42
|
+
server.render_error = None
|
|
43
|
+
server.render_event = threading.Event()
|
|
44
|
+
server.asset_root = source_path.parent
|
|
45
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
46
|
+
thread.start()
|
|
47
|
+
browser = None
|
|
48
|
+
profile = Path(tempfile.mkdtemp(prefix="pugflow-render-"))
|
|
49
|
+
try:
|
|
50
|
+
url = f"http://127.0.0.1:{server.server_address[1]}/render.html?scale={scale}"
|
|
51
|
+
browser = subprocess.Popen([find_browser(), "--headless=new", "--disable-gpu", "--no-first-run", "--disable-extensions", f"--user-data-dir={profile}", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
52
|
+
if not server.render_event.wait(timeout):
|
|
53
|
+
raise RuntimeError(f"Rendering timed out after {timeout:g} seconds.")
|
|
54
|
+
if server.render_error:
|
|
55
|
+
raise RuntimeError(server.render_error)
|
|
56
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
output_path.write_bytes(server.render_result)
|
|
58
|
+
finally:
|
|
59
|
+
if browser and browser.poll() is None:
|
|
60
|
+
try:
|
|
61
|
+
browser.wait(timeout=3)
|
|
62
|
+
except subprocess.TimeoutExpired:
|
|
63
|
+
browser.terminate()
|
|
64
|
+
try:
|
|
65
|
+
browser.wait(timeout=3)
|
|
66
|
+
except subprocess.TimeoutExpired:
|
|
67
|
+
browser.kill()
|
|
68
|
+
server.shutdown()
|
|
69
|
+
server.server_close()
|
|
70
|
+
thread.join(timeout=2)
|
|
71
|
+
shutil.rmtree(profile, ignore_errors=True)
|
pugflow/server.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Dependency-free HTTP server for the Pugflow frontend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
import webbrowser
|
|
8
|
+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import quote, urlsplit
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
STATIC_ROOT = Path(__file__).resolve().with_name("web")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DiagramRequestHandler(SimpleHTTPRequestHandler):
|
|
19
|
+
"""Serve packaged assets and expose a small health endpoint."""
|
|
20
|
+
|
|
21
|
+
server_version = f"Pugflow/{__version__}"
|
|
22
|
+
extensions_map = {**SimpleHTTPRequestHandler.extensions_map, ".mjs": "text/javascript"}
|
|
23
|
+
|
|
24
|
+
def __init__(self, *args, directory=None, **kwargs):
|
|
25
|
+
super().__init__(*args, directory=str(directory or STATIC_ROOT), **kwargs)
|
|
26
|
+
|
|
27
|
+
def do_GET(self): # noqa: N802 - required by BaseHTTPRequestHandler
|
|
28
|
+
path = urlsplit(self.path).path
|
|
29
|
+
if path == "/healthz":
|
|
30
|
+
payload = json.dumps({"status": "ok", "version": __version__}).encode("utf-8")
|
|
31
|
+
self.send_response(200)
|
|
32
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
33
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
34
|
+
self.send_header("Cache-Control", "no-store")
|
|
35
|
+
self.end_headers()
|
|
36
|
+
self.wfile.write(payload)
|
|
37
|
+
return
|
|
38
|
+
if path == "/__render_source.pug" and hasattr(self.server, "render_source"):
|
|
39
|
+
return self._send_text(self.server.render_source, "text/plain; charset=utf-8")
|
|
40
|
+
if path == "/__render_styles.css" and hasattr(self.server, "render_styles"):
|
|
41
|
+
return self._send_text(self.server.render_styles, "text/css; charset=utf-8")
|
|
42
|
+
if path == "/__project.pug" and hasattr(self.server, "project_pug"):
|
|
43
|
+
return self._send_text(self.server.project_pug, "text/plain; charset=utf-8")
|
|
44
|
+
if path == "/__project.css" and hasattr(self.server, "project_css"):
|
|
45
|
+
return self._send_text(self.server.project_css, "text/css; charset=utf-8")
|
|
46
|
+
if hasattr(self.server, "asset_root") and path not in {"/", "/render.html"}:
|
|
47
|
+
asset = (self.server.asset_root / path.lstrip("/")).resolve()
|
|
48
|
+
if asset.is_relative_to(self.server.asset_root.resolve()) and asset.is_file():
|
|
49
|
+
self.directory = str(self.server.asset_root)
|
|
50
|
+
super().do_GET()
|
|
51
|
+
|
|
52
|
+
def do_POST(self): # noqa: N802 - required by BaseHTTPRequestHandler
|
|
53
|
+
path = urlsplit(self.path).path
|
|
54
|
+
if path not in {"/__render_output", "/__render_error"} or not hasattr(self.server, "render_event"):
|
|
55
|
+
self.send_error(404)
|
|
56
|
+
return
|
|
57
|
+
payload = self.rfile.read(int(self.headers.get("Content-Length", "0")))
|
|
58
|
+
if path == "/__render_output":
|
|
59
|
+
self.server.render_result = payload
|
|
60
|
+
else:
|
|
61
|
+
self.server.render_error = payload.decode("utf-8", errors="replace")
|
|
62
|
+
self.send_response(204)
|
|
63
|
+
self.end_headers()
|
|
64
|
+
self.server.render_event.set()
|
|
65
|
+
|
|
66
|
+
def _send_text(self, value, content_type):
|
|
67
|
+
payload = value.encode("utf-8")
|
|
68
|
+
self.send_response(200)
|
|
69
|
+
self.send_header("Content-Type", content_type)
|
|
70
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
71
|
+
self.end_headers()
|
|
72
|
+
self.wfile.write(payload)
|
|
73
|
+
|
|
74
|
+
def end_headers(self):
|
|
75
|
+
if urlsplit(self.path).path != "/healthz":
|
|
76
|
+
self.send_header("Cache-Control", "no-cache")
|
|
77
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
78
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
79
|
+
super().end_headers()
|
|
80
|
+
|
|
81
|
+
def log_message(self, format, *args):
|
|
82
|
+
if not getattr(self.server, "quiet", False):
|
|
83
|
+
super().log_message(format, *args)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class DiagramServer(ThreadingHTTPServer):
|
|
87
|
+
"""Threaded development/desktop server with clean process shutdown."""
|
|
88
|
+
|
|
89
|
+
allow_reuse_address = True
|
|
90
|
+
daemon_threads = True
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def create_server(host="127.0.0.1", port=4173, *, quiet=False):
|
|
94
|
+
"""Create a configured server without starting its blocking loop."""
|
|
95
|
+
|
|
96
|
+
if not STATIC_ROOT.joinpath("index.html").is_file():
|
|
97
|
+
raise RuntimeError(
|
|
98
|
+
f"Packaged web assets are missing from {STATIC_ROOT}."
|
|
99
|
+
)
|
|
100
|
+
server = DiagramServer((host, port), DiagramRequestHandler)
|
|
101
|
+
server.quiet = quiet
|
|
102
|
+
return server
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def serve(host="127.0.0.1", port=4173, *, open_browser=True, quiet=False, vim=False, demo=False, pug_path=None, css_path=None):
|
|
106
|
+
"""Run the web application until interrupted and return the bound URL."""
|
|
107
|
+
|
|
108
|
+
server = create_server(host, port, quiet=quiet)
|
|
109
|
+
if pug_path:
|
|
110
|
+
server.project_pug = Path(pug_path).read_text(encoding="utf-8")
|
|
111
|
+
server.project_css = Path(css_path).read_text(encoding="utf-8") if css_path else ""
|
|
112
|
+
actual_port = server.server_address[1]
|
|
113
|
+
browser_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host
|
|
114
|
+
query = []
|
|
115
|
+
if vim: query.append("vim=1")
|
|
116
|
+
if demo: query.append("demo=1")
|
|
117
|
+
if pug_path:
|
|
118
|
+
query.extend(["project=1", f"name={quote(Path(pug_path).stem)}", f"pug_name={quote(Path(pug_path).name)}"])
|
|
119
|
+
if css_path: query.append(f"css_name={quote(Path(css_path).name)}")
|
|
120
|
+
url = f"http://{browser_host}:{actual_port}" + ("?" + "&".join(query) if query else "")
|
|
121
|
+
print(f"Pugflow {__version__}: {url}")
|
|
122
|
+
print("Press Ctrl+C to stop.")
|
|
123
|
+
|
|
124
|
+
if open_browser:
|
|
125
|
+
threading.Timer(0.25, webbrowser.open, args=(url,)).start()
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
server.serve_forever()
|
|
129
|
+
except KeyboardInterrupt:
|
|
130
|
+
print("\nStopping Pugflow.")
|
|
131
|
+
finally:
|
|
132
|
+
server.server_close()
|
|
133
|
+
return url
|