mdgate 2.0.0__tar.gz

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.
mdgate-2.0.0/PKG-INFO ADDED
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: mdgate
3
+ Version: 2.0.0
4
+ Summary: Serve markdown files as mobile-friendly web pages over Tailscale
5
+ License-Expression: ISC
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: mistune>=3.0
8
+ Requires-Dist: pygments>=2.17
@@ -0,0 +1,20 @@
1
+ [project]
2
+ name = "mdgate"
3
+ version = "2.0.0"
4
+ description = "Serve markdown files as mobile-friendly web pages over Tailscale"
5
+ requires-python = ">=3.11"
6
+ license = "ISC"
7
+ dependencies = [
8
+ "mistune>=3.0",
9
+ "pygments>=2.17",
10
+ ]
11
+
12
+ [project.scripts]
13
+ mdgate = "mdgate.cli:main"
14
+
15
+ [build-system]
16
+ requires = ["hatchling"]
17
+ build-backend = "hatchling.build"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/mdgate"]
@@ -0,0 +1 @@
1
+ __version__ = "2.0.0"
@@ -0,0 +1,3 @@
1
+ from mdgate.cli import main
2
+
3
+ main()
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import signal
6
+ import sys
7
+ from pathlib import Path
8
+ from urllib.request import Request, urlopen
9
+ from urllib.error import URLError
10
+
11
+ from .config import load_config, init_config
12
+
13
+
14
+ def main():
15
+ args = sys.argv[1:]
16
+ config = load_config()
17
+
18
+ if not args or "--help" in args or "-h" in args:
19
+ _cmd_help(config)
20
+ return
21
+
22
+ if "--init" in args:
23
+ _cmd_init(args)
24
+ return
25
+
26
+ if "--stop" in args:
27
+ _cmd_stop()
28
+ return
29
+
30
+ if "--status" in args:
31
+ _cmd_status()
32
+ return
33
+
34
+ is_review = args[0] == "review"
35
+ is_daemon = "--daemon" in args
36
+ effective_args = args[1:] if is_review else args
37
+
38
+ port = config["port"]
39
+ file_path = None
40
+ share_name = None
41
+ share_enabled = False
42
+
43
+ i = 0
44
+ while i < len(effective_args):
45
+ arg = effective_args[i]
46
+ if arg in ("-p", "--port") and i + 1 < len(effective_args):
47
+ port = int(effective_args[i + 1])
48
+ i += 2
49
+ continue
50
+ elif arg == "--share":
51
+ share_enabled = True
52
+ elif arg.startswith("--share="):
53
+ share_enabled = True
54
+ share_name = arg.removeprefix("--share=")
55
+ elif not arg.startswith("-"):
56
+ file_path = str(Path(arg).resolve())
57
+ i += 1
58
+
59
+ if is_daemon:
60
+ from .server import start_server
61
+ start_server(None, port, config["hosts"], daemon=True)
62
+ if share_enabled:
63
+ from .zrok import start_zrok
64
+ start_zrok(port, share_name)
65
+ elif not file_path:
66
+ print("Error: No markdown file specified", file=sys.stderr)
67
+ sys.exit(1)
68
+ elif not Path(file_path).exists():
69
+ print(f"Error: File not found: {file_path}", file=sys.stderr)
70
+ sys.exit(1)
71
+ elif is_review:
72
+ from .server import start_server
73
+ comments = start_server(file_path, port, config["hosts"], review_mode=True)
74
+ print(json.dumps(comments, indent=2))
75
+ sys.exit(0)
76
+ else:
77
+ _cmd_serve(file_path, port, config["hosts"], share_enabled, share_name)
78
+
79
+
80
+ def _cmd_serve(file_path: str, port: int, hosts: list[str], share_enabled: bool, share_name: str | None):
81
+ if _is_server_running(port):
82
+ slug = _register_with_running_server(file_path, port)
83
+ print(f"Registered: http://localhost:{port}/{slug}/")
84
+ for h in hosts:
85
+ print(f" http://{h}:{port}/{slug}/")
86
+ else:
87
+ from .server import start_server
88
+ if share_enabled:
89
+ from .zrok import start_zrok
90
+ start_zrok(port, share_name)
91
+ start_server(file_path, port, hosts)
92
+
93
+
94
+ def _is_server_running(port: int) -> bool:
95
+ try:
96
+ req = Request(f"http://127.0.0.1:{port}/health")
97
+ with urlopen(req, timeout=2) as resp:
98
+ data = json.loads(resp.read())
99
+ return data.get("status") == "ok"
100
+ except Exception:
101
+ return False
102
+
103
+
104
+ def _register_with_running_server(file_path: str, port: int) -> str:
105
+ abs_file = str(Path(file_path).resolve())
106
+ base_dir = str(Path(abs_file).parent)
107
+ body = json.dumps({"filePath": abs_file, "baseDir": base_dir}).encode()
108
+ req = Request(f"http://127.0.0.1:{port}/_api/register", data=body,
109
+ headers={"Content-Type": "application/json"}, method="POST")
110
+ with urlopen(req, timeout=5) as resp:
111
+ data = json.loads(resp.read())
112
+ return data["slug"]
113
+
114
+
115
+ def _cmd_help(config: dict):
116
+ print(f"""mdgate — Serve markdown files as mobile-friendly web pages
117
+
118
+ Usage:
119
+ mdgate <file.md> Register and serve a markdown file
120
+ mdgate review <file.md> Serve for review, block until submitted
121
+ mdgate --daemon Start server without a document (background mode)
122
+ mdgate --init <host1> [host2...] Set Tailscale hostnames
123
+ mdgate --stop Stop the running server
124
+ mdgate --status Check server status
125
+
126
+ Options:
127
+ -p, --port <port> Port to listen on (default: {config['port']})
128
+ --share[=name] Expose via zrok (optional fixed name)
129
+ -h, --help Show this help
130
+
131
+ Documents persist across server restarts.
132
+ Remove documents from the web dashboard at http://localhost:{config['port']}/
133
+
134
+ Config: ~/.mdgate/config.json""")
135
+
136
+
137
+ def _cmd_init(args: list[str]):
138
+ idx = args.index("--init")
139
+ hosts = [a for a in args[idx + 1:] if not a.startswith("-")]
140
+ if not hosts:
141
+ print("Usage: mdgate --init <host1> [host2...]", file=sys.stderr)
142
+ sys.exit(1)
143
+ init_config(hosts)
144
+
145
+
146
+ def _cmd_stop():
147
+ pid_file = Path.home() / ".mdgate" / "server.pid"
148
+ if pid_file.exists():
149
+ pid = int(pid_file.read_text().strip())
150
+ try:
151
+ os.kill(pid, signal.SIGTERM)
152
+ pid_file.unlink(missing_ok=True)
153
+ print(f"Stopped mdgate server (pid {pid})")
154
+ except ProcessLookupError:
155
+ pid_file.unlink(missing_ok=True)
156
+ print("Server was not running (stale pid file removed)")
157
+ else:
158
+ print("No mdgate server is running")
159
+
160
+
161
+ def _cmd_status():
162
+ pid_file = Path.home() / ".mdgate" / "server.pid"
163
+ if pid_file.exists():
164
+ pid = int(pid_file.read_text().strip())
165
+ try:
166
+ os.kill(pid, 0)
167
+ print(f"mdgate server is running (pid {pid})")
168
+ except ProcessLookupError:
169
+ print("mdgate server is not running (stale pid file)")
170
+ else:
171
+ print("No mdgate server is running")
@@ -0,0 +1,60 @@
1
+ import json
2
+ import time
3
+ import random
4
+ import string
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+
9
+ def _comments_path(md_abs_path: str) -> Path:
10
+ return Path(md_abs_path + ".comments.json")
11
+
12
+
13
+ def _make_id() -> str:
14
+ t = int(time.time() * 1000)
15
+ rand = "".join(random.choices(string.ascii_lowercase + string.digits, k=4))
16
+ return f"{t:x}{rand}"
17
+
18
+
19
+ def load_comments(md_abs_path: str) -> list[dict]:
20
+ try:
21
+ return json.loads(_comments_path(md_abs_path).read_text())
22
+ except Exception:
23
+ return []
24
+
25
+
26
+ def add_comment(md_abs_path: str, section: str, text: str) -> dict:
27
+ comments = load_comments(md_abs_path)
28
+ entry = {
29
+ "id": _make_id(),
30
+ "section": section,
31
+ "text": text,
32
+ "ts": datetime.now(timezone.utc).isoformat(),
33
+ }
34
+ comments.append(entry)
35
+ _comments_path(md_abs_path).write_text(json.dumps(comments, indent=2) + "\n")
36
+ return entry
37
+
38
+
39
+ def update_comment(md_abs_path: str, comment_id: str, new_text: str) -> dict | None:
40
+ comments = load_comments(md_abs_path)
41
+ comment = next((c for c in comments if c["id"] == comment_id), None)
42
+ if not comment:
43
+ return None
44
+ comment["text"] = new_text
45
+ comment["editedAt"] = datetime.now(timezone.utc).isoformat()
46
+ _comments_path(md_abs_path).write_text(json.dumps(comments, indent=2) + "\n")
47
+ return comment
48
+
49
+
50
+ def clear_comments(md_abs_path: str):
51
+ _comments_path(md_abs_path).write_text("[]\n")
52
+
53
+
54
+ def delete_comment(md_abs_path: str, comment_id: str) -> bool:
55
+ comments = load_comments(md_abs_path)
56
+ filtered = [c for c in comments if c["id"] != comment_id]
57
+ if len(filtered) == len(comments):
58
+ return False
59
+ _comments_path(md_abs_path).write_text(json.dumps(filtered, indent=2) + "\n")
60
+ return True
@@ -0,0 +1,23 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ CONFIG_DIR = Path.home() / ".mdgate"
5
+ CONFIG_FILE = CONFIG_DIR / "config.json"
6
+
7
+ DEFAULTS = {"port": 9483, "hosts": []}
8
+
9
+
10
+ def load_config() -> dict:
11
+ try:
12
+ raw = json.loads(CONFIG_FILE.read_text())
13
+ return {**DEFAULTS, **raw}
14
+ except Exception:
15
+ return {**DEFAULTS}
16
+
17
+
18
+ def init_config(hosts: list[str]) -> dict:
19
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
20
+ config = {**DEFAULTS, "hosts": hosts}
21
+ CONFIG_FILE.write_text(json.dumps(config, indent=2) + "\n")
22
+ print(f"Config written to {CONFIG_FILE}")
23
+ return config
@@ -0,0 +1,60 @@
1
+ import json
2
+ from datetime import datetime, timezone
3
+ from pathlib import Path
4
+
5
+ STATE_DIR = Path.home() / ".mdgate"
6
+ REGISTRY_FILE = STATE_DIR / "registry.json"
7
+
8
+
9
+ def load_registry() -> list[dict]:
10
+ try:
11
+ return json.loads(REGISTRY_FILE.read_text())
12
+ except Exception:
13
+ return []
14
+
15
+
16
+ def save_registry(entries: list[dict]):
17
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
18
+ REGISTRY_FILE.write_text(json.dumps(entries, indent=2) + "\n")
19
+
20
+
21
+ def _make_slug(file_path: str) -> str:
22
+ p = Path(file_path)
23
+ parent = p.parent.name
24
+ name = p.stem
25
+ return f"{parent}/{name}"
26
+
27
+
28
+ def add_entry(file_path: str, base_dir: str) -> str:
29
+ entries = load_registry()
30
+ abs_path = str(Path(file_path).resolve())
31
+
32
+ existing = next((e for e in entries if e["filePath"] == abs_path), None)
33
+ if existing:
34
+ return existing["slug"]
35
+
36
+ base = _make_slug(abs_path)
37
+ slug = base
38
+ i = 2
39
+ while any(e["slug"] == slug for e in entries):
40
+ slug = f"{base}-{i}"
41
+ i += 1
42
+
43
+ entries.append({
44
+ "slug": slug,
45
+ "filePath": abs_path,
46
+ "baseDir": str(Path(base_dir).resolve()),
47
+ "entryFile": Path(file_path).name,
48
+ "registeredAt": datetime.now(timezone.utc).isoformat(),
49
+ })
50
+ save_registry(entries)
51
+ return slug
52
+
53
+
54
+ def remove_entry(slug: str) -> bool:
55
+ entries = load_registry()
56
+ filtered = [e for e in entries if e["slug"] != slug]
57
+ if len(filtered) == len(entries):
58
+ return False
59
+ save_registry(filtered)
60
+ return True