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 +8 -0
- mdgate-2.0.0/pyproject.toml +20 -0
- mdgate-2.0.0/src/mdgate/__init__.py +1 -0
- mdgate-2.0.0/src/mdgate/__main__.py +3 -0
- mdgate-2.0.0/src/mdgate/cli.py +171 -0
- mdgate-2.0.0/src/mdgate/comments.py +60 -0
- mdgate-2.0.0/src/mdgate/config.py +23 -0
- mdgate-2.0.0/src/mdgate/registry.py +60 -0
- mdgate-2.0.0/src/mdgate/server.py +436 -0
- mdgate-2.0.0/src/mdgate/template.py +1200 -0
- mdgate-2.0.0/src/mdgate/zrok.py +49 -0
- mdgate-2.0.0/uv.lock +36 -0
mdgate-2.0.0/PKG-INFO
ADDED
|
@@ -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,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
|