markserv 1.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.
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.3
2
+ Name: markserv
3
+ Version: 1.0.0
4
+ Summary: Live-render GitHub-flavored markdown files and folders
5
+ Author: Nathan Gage
6
+ Author-email: Nathan Gage <nathan@sweetspot.so>
7
+ Requires-Dist: cmarkgfm>=2024.11.20
8
+ Requires-Dist: cyclopts>=4.10.1
9
+ Requires-Dist: fasthx[htmy]>=3.1.0
10
+ Requires-Dist: fastapi>=0.115.12
11
+ Requires-Dist: ignoretree>=0.2.0
12
+ Requires-Dist: rich>=14.0.0
13
+ Requires-Dist: uvicorn>=0.34.0
14
+ Requires-Dist: watchfiles>=1.0.5
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+
18
+ # markserv
19
+
20
+ `markserv` is a small FastAPI-based markdown preview server for local docs.
21
+
22
+ It uses:
23
+
24
+ - [`cmarkgfm`](https://github.com/theacodes/cmarkgfm) for GitHub-flavored markdown rendering
25
+ - [`FastAPI`](https://fastapi.tiangolo.com/) for the local web app
26
+ - [`FastHX`](https://github.com/volfpeter/fasthx) for HTMX-aware FastAPI rendering
27
+ - [`htmy`](https://github.com/volfpeter/htmy) for Python component-based HTML rendering
28
+ - [`github-markdown-css`](https://github.com/sindresorhus/github-markdown-css) for GitHub-like styling
29
+ - [`watchfiles`](https://github.com/samuelcolvin/watchfiles) for live reload
30
+ - [`ignoretree`](https://pypi.org/project/ignoretree/) to respect `.gitignore` rules while scanning
31
+
32
+ ## Install
33
+
34
+ Once published:
35
+
36
+ ```bash
37
+ uv tool install markserv
38
+ ```
39
+
40
+ For local development:
41
+
42
+ ```bash
43
+ uv tool install .
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ Serve a directory of markdown files:
49
+
50
+ ```bash
51
+ markserv docs/
52
+ ```
53
+
54
+ Serve a single markdown file:
55
+
56
+ ```bash
57
+ markserv README.md
58
+ ```
59
+
60
+ Run the built-in synthetic demo site:
61
+
62
+ ```bash
63
+ markserv.demo
64
+ ```
65
+
66
+ Options:
67
+
68
+ ```bash
69
+ markserv .
70
+ markserv --host localhost --port 4422 .
71
+ markserv --no-open README.md
72
+ markserv.demo --no-open --port 9001
73
+ ```
74
+
75
+ ## Behavior
76
+
77
+ - Renders common markdown extensions like `.md` and `.markdown`
78
+ - Watches markdown files and reloads the browser when content changes
79
+ - Respects `.gitignore` while scanning so ignored trees like `.venv/` are skipped
80
+ - Serves linked local assets like images from the same file tree
81
+ - In directory mode, shows a sidebar for browsing multiple markdown pages
82
+ - Includes a system/light/dark theme control that remembers your choice in browser storage
83
+
84
+ ## Development
85
+
86
+ Install dev tooling:
87
+
88
+ ```bash
89
+ make install
90
+ ```
91
+
92
+ Common commands:
93
+
94
+ ```bash
95
+ make format
96
+ make format-check
97
+ make lint
98
+ make typecheck
99
+ make test
100
+ make all-ci
101
+ ```
102
+
103
+ ## Notes
104
+
105
+ - This is intended for plain markdown / GFM-style docs, not MDX.
106
+ - UI components are rendered with `htmy` from Python.
107
+ - Front-end assets live under `src/markserv/public/`.
108
+ - Bundled CSS comes from `github-markdown-css`.
109
+ - Bundled HTMX assets are used for SSE-driven live updates.
110
+ - The upstream stylesheet license is included at `src/markserv/public/licenses/github-markdown-css.LICENSE`.
111
+ - The bundled HTMX license is included at `src/markserv/public/licenses/htmx.LICENSE`.
@@ -0,0 +1,94 @@
1
+ # markserv
2
+
3
+ `markserv` is a small FastAPI-based markdown preview server for local docs.
4
+
5
+ It uses:
6
+
7
+ - [`cmarkgfm`](https://github.com/theacodes/cmarkgfm) for GitHub-flavored markdown rendering
8
+ - [`FastAPI`](https://fastapi.tiangolo.com/) for the local web app
9
+ - [`FastHX`](https://github.com/volfpeter/fasthx) for HTMX-aware FastAPI rendering
10
+ - [`htmy`](https://github.com/volfpeter/htmy) for Python component-based HTML rendering
11
+ - [`github-markdown-css`](https://github.com/sindresorhus/github-markdown-css) for GitHub-like styling
12
+ - [`watchfiles`](https://github.com/samuelcolvin/watchfiles) for live reload
13
+ - [`ignoretree`](https://pypi.org/project/ignoretree/) to respect `.gitignore` rules while scanning
14
+
15
+ ## Install
16
+
17
+ Once published:
18
+
19
+ ```bash
20
+ uv tool install markserv
21
+ ```
22
+
23
+ For local development:
24
+
25
+ ```bash
26
+ uv tool install .
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ Serve a directory of markdown files:
32
+
33
+ ```bash
34
+ markserv docs/
35
+ ```
36
+
37
+ Serve a single markdown file:
38
+
39
+ ```bash
40
+ markserv README.md
41
+ ```
42
+
43
+ Run the built-in synthetic demo site:
44
+
45
+ ```bash
46
+ markserv.demo
47
+ ```
48
+
49
+ Options:
50
+
51
+ ```bash
52
+ markserv .
53
+ markserv --host localhost --port 4422 .
54
+ markserv --no-open README.md
55
+ markserv.demo --no-open --port 9001
56
+ ```
57
+
58
+ ## Behavior
59
+
60
+ - Renders common markdown extensions like `.md` and `.markdown`
61
+ - Watches markdown files and reloads the browser when content changes
62
+ - Respects `.gitignore` while scanning so ignored trees like `.venv/` are skipped
63
+ - Serves linked local assets like images from the same file tree
64
+ - In directory mode, shows a sidebar for browsing multiple markdown pages
65
+ - Includes a system/light/dark theme control that remembers your choice in browser storage
66
+
67
+ ## Development
68
+
69
+ Install dev tooling:
70
+
71
+ ```bash
72
+ make install
73
+ ```
74
+
75
+ Common commands:
76
+
77
+ ```bash
78
+ make format
79
+ make format-check
80
+ make lint
81
+ make typecheck
82
+ make test
83
+ make all-ci
84
+ ```
85
+
86
+ ## Notes
87
+
88
+ - This is intended for plain markdown / GFM-style docs, not MDX.
89
+ - UI components are rendered with `htmy` from Python.
90
+ - Front-end assets live under `src/markserv/public/`.
91
+ - Bundled CSS comes from `github-markdown-css`.
92
+ - Bundled HTMX assets are used for SSE-driven live updates.
93
+ - The upstream stylesheet license is included at `src/markserv/public/licenses/github-markdown-css.LICENSE`.
94
+ - The bundled HTMX license is included at `src/markserv/public/licenses/htmx.LICENSE`.
@@ -0,0 +1,85 @@
1
+ [project]
2
+ name = "markserv"
3
+ version = "1.0.0"
4
+ description = "Live-render GitHub-flavored markdown files and folders"
5
+ readme = "README.md"
6
+ authors = [{ name = "Nathan Gage", email = "nathan@sweetspot.so" }]
7
+ requires-python = ">=3.11"
8
+ dependencies = [
9
+ "cmarkgfm>=2024.11.20",
10
+ "cyclopts>=4.10.1",
11
+ "fasthx[htmy]>=3.1.0",
12
+ "fastapi>=0.115.12",
13
+ "ignoretree>=0.2.0",
14
+ "rich>=14.0.0",
15
+ "uvicorn>=0.34.0",
16
+ "watchfiles>=1.0.5",
17
+ ]
18
+
19
+ [dependency-groups]
20
+ dev = ["httpx>=0.28.1", "pytest>=8.4.0"]
21
+ lint = ["mypy>=1.15.0", "pyright>=1.1.408", "ruff>=0.14.0", "taplo>=0.9.3"]
22
+
23
+ [project.scripts]
24
+ markserv = "markserv.cli:main"
25
+ "markserv.demo" = "markserv.demo:main"
26
+
27
+ [tool.uv]
28
+ default-groups = ["dev", "lint"]
29
+
30
+ [tool.uv.build-backend]
31
+ package-data = { "markserv" = [
32
+ "public/**/*.css",
33
+ "public/**/*.js",
34
+ "public/**/*.LICENSE",
35
+ ] }
36
+
37
+ [tool.ruff]
38
+ line-length = 120
39
+ target-version = "py311"
40
+ include = ["src/**/*.py", "tests/**/*.py"]
41
+
42
+ [tool.ruff.lint]
43
+ extend-select = ["B", "FAST", "I", "Q", "RUF100", "UP"]
44
+
45
+ [tool.ruff.lint.flake8-quotes]
46
+ inline-quotes = "double"
47
+ multiline-quotes = "double"
48
+
49
+ [tool.ruff.format]
50
+ docstring-code-format = false
51
+ quote-style = "double"
52
+
53
+ [tool.pyright]
54
+ pythonVersion = "3.11"
55
+ typeCheckingMode = "standard"
56
+ reportMissingTypeStubs = false
57
+ reportUnnecessaryTypeIgnoreComment = true
58
+ include = ["src", "tests"]
59
+ stubPath = "typings"
60
+ venvPath = "."
61
+ venv = ".venv"
62
+ executionEnvironments = [
63
+ { root = ".", extraPaths = [
64
+ "src",
65
+ ] },
66
+ { root = "tests", extraPaths = [
67
+ "src",
68
+ ] },
69
+ ]
70
+
71
+ [tool.mypy]
72
+ python_version = "3.11"
73
+ files = ["src", "tests"]
74
+ mypy_path = "$MYPY_CONFIG_FILE_DIR/src:$MYPY_CONFIG_FILE_DIR/typings"
75
+ strict = true
76
+
77
+ [tool.pytest.ini_options]
78
+ addopts = "-ra"
79
+ testpaths = ["tests"]
80
+ xfail_strict = true
81
+ filterwarnings = ["error"]
82
+
83
+ [build-system]
84
+ requires = ["uv_build>=0.11.2,<0.12.0"]
85
+ build-backend = "uv_build"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ __all__ = ["main"]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,4 @@
1
+ from .site import WatchPathFilter, build_config
2
+ from .web import create_app
3
+
4
+ __all__ = ["WatchPathFilter", "build_config", "create_app"]
@@ -0,0 +1,279 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import logging
5
+ import os
6
+ import select
7
+ import sys
8
+ import threading
9
+ import webbrowser
10
+ from collections.abc import Mapping
11
+ from pathlib import Path
12
+ from typing import Annotated, Any, Protocol
13
+
14
+ import uvicorn
15
+ from cyclopts import App, Parameter
16
+ from cyclopts.help import PlainFormatter
17
+ from cyclopts.token import Token
18
+ from rich.console import Console
19
+ from rich.logging import RichHandler
20
+ from uvicorn import Config, Server
21
+
22
+ from .app import create_app
23
+ from .site import build_config, build_file_site
24
+
25
+
26
+ class StoppableServer(Protocol):
27
+ should_exit: bool
28
+
29
+ def run(self) -> None: ...
30
+
31
+
32
+ console = Console(stderr=True)
33
+ QUIT_KEYS = {"q", "Q", "\x1b"}
34
+ DEFAULT_HOST = "localhost"
35
+ DEFAULT_PORT = 4422
36
+ PYTHON_RELOAD_ENV_VAR = "MARKSERV_PYTHON_RELOAD"
37
+ TARGET_ENV_VAR = "MARKSERV_TARGET"
38
+ PYTHON_RELOAD_DIR = Path(__file__).resolve().parent
39
+
40
+ app = App(
41
+ name="markserv",
42
+ help="Render a folder of GitHub-flavored markdown with live reload.",
43
+ help_formatter=PlainFormatter(),
44
+ result_action="return_value",
45
+ )
46
+
47
+
48
+ def _validate_target(_type_: Any, tokens: tuple[Token, ...]) -> Path:
49
+ raw_path = Path(tokens[0].value)
50
+ build_config(raw_path)
51
+ return raw_path
52
+
53
+
54
+ def configure_logging() -> None:
55
+ logging.basicConfig(
56
+ level=logging.INFO,
57
+ format="%(message)s",
58
+ handlers=[
59
+ RichHandler(
60
+ show_time=False,
61
+ show_level=False,
62
+ show_path=False,
63
+ markup=False,
64
+ rich_tracebacks=True,
65
+ console=console,
66
+ )
67
+ ],
68
+ force=True,
69
+ )
70
+
71
+
72
+ def browser_url(host: str, port: int) -> str:
73
+ public_host = "localhost" if host in {"127.0.0.1", "0.0.0.0", "localhost"} else host
74
+ return f"http://{public_host}:{port}"
75
+
76
+
77
+ def python_reload_enabled() -> bool:
78
+ value = os.environ.get(PYTHON_RELOAD_ENV_VAR, "")
79
+ return value.lower() in {"1", "true", "yes", "on"}
80
+
81
+
82
+ @contextlib.contextmanager
83
+ def temporary_env(updates: Mapping[str, str]) -> Any:
84
+ previous = {key: os.environ.get(key) for key in updates}
85
+ os.environ.update(updates)
86
+ try:
87
+ yield
88
+ finally:
89
+ for key, value in previous.items():
90
+ if value is None:
91
+ os.environ.pop(key, None)
92
+ else:
93
+ os.environ[key] = value
94
+
95
+
96
+ def print_startup_banner(*, source: str, root_dir: str, url: str, open_browser: bool, python_reload: bool) -> None:
97
+ quit_hint = (
98
+ "Press Ctrl+C to quit."
99
+ if python_reload
100
+ else "Press Q or Esc to quit."
101
+ if _supports_quit_prompt()
102
+ else "Press Ctrl+C to quit."
103
+ )
104
+ browser_hint = "Browser opens automatically." if open_browser else "Browser auto-open disabled."
105
+ reload_hint = "Python reload enabled via MARKSERV_PYTHON_RELOAD." if python_reload else None
106
+ display_url = url.removeprefix("http://")
107
+
108
+ console.print(f"[bold cyan]markserv[/] serving {source}")
109
+ console.print(f"[cyan]root[/] {root_dir}")
110
+ console.print(f"[cyan]url[/] [link={url}][underline]{display_url}[/underline][/link]")
111
+ console.print(f"[dim]{browser_hint}[/]")
112
+ if reload_hint is not None:
113
+ console.print(f"[dim]{reload_hint}[/]")
114
+ console.print(f"[dim]{quit_hint}[/]")
115
+
116
+
117
+ def create_server(app: Any, *, host: str, port: int) -> Server:
118
+ return Server(
119
+ Config(
120
+ app,
121
+ host=host,
122
+ port=port,
123
+ log_level="warning",
124
+ access_log=False,
125
+ log_config=None,
126
+ )
127
+ )
128
+
129
+
130
+ def run_python_reloading_server(app_factory_import: str, *, host: str, port: int) -> None:
131
+ uvicorn.run(
132
+ app_factory_import,
133
+ factory=True,
134
+ host=host,
135
+ port=port,
136
+ reload=True,
137
+ reload_dirs=[str(PYTHON_RELOAD_DIR)],
138
+ log_level="warning",
139
+ access_log=False,
140
+ log_config=None,
141
+ )
142
+
143
+
144
+ def _supports_quit_prompt() -> bool:
145
+ return sys.stdin.isatty() and os.name != "nt"
146
+
147
+
148
+ def _request_server_shutdown(server: StoppableServer, stop_event: threading.Event) -> None:
149
+ if stop_event.is_set():
150
+ return
151
+ console.print("[dim]Stopping server...[/dim]")
152
+ server.should_exit = True
153
+ stop_event.set()
154
+
155
+
156
+ def _listen_for_quit_keys(server: StoppableServer, stop_event: threading.Event) -> None:
157
+ import termios
158
+ import tty
159
+
160
+ with contextlib.suppress(termios.error, ValueError, OSError):
161
+ fd = sys.stdin.fileno()
162
+ original_attrs = termios.tcgetattr(fd)
163
+ try:
164
+ tty.setcbreak(fd)
165
+ while not stop_event.is_set():
166
+ readable, _writable, _errors = select.select([sys.stdin], [], [], 0.1)
167
+ if not readable:
168
+ continue
169
+ key = sys.stdin.read(1)
170
+ if key in QUIT_KEYS:
171
+ _request_server_shutdown(server, stop_event)
172
+ return
173
+ finally:
174
+ termios.tcsetattr(fd, termios.TCSADRAIN, original_attrs)
175
+
176
+
177
+ def run_server(server: StoppableServer) -> None:
178
+ stop_event = threading.Event()
179
+ listener: threading.Thread | None = None
180
+
181
+ if _supports_quit_prompt():
182
+ listener = threading.Thread(
183
+ target=_listen_for_quit_keys,
184
+ args=(server, stop_event),
185
+ daemon=True,
186
+ name="markserv-quit-listener",
187
+ )
188
+ listener.start()
189
+
190
+ try:
191
+ server.run()
192
+ finally:
193
+ stop_event.set()
194
+ if listener is not None:
195
+ listener.join(timeout=0.2)
196
+
197
+
198
+ def serve_application(
199
+ application: Any | None,
200
+ *,
201
+ source: str,
202
+ root_dir: str,
203
+ host: str,
204
+ port: int,
205
+ open_browser: bool,
206
+ app_factory_import: str | None = None,
207
+ env_updates: Mapping[str, str] | None = None,
208
+ ) -> None:
209
+ configure_logging()
210
+ url = browser_url(host, port)
211
+ python_reload = python_reload_enabled()
212
+ print_startup_banner(
213
+ source=source,
214
+ root_dir=root_dir,
215
+ url=url,
216
+ open_browser=open_browser,
217
+ python_reload=python_reload,
218
+ )
219
+
220
+ if open_browser:
221
+ threading.Timer(0.8, lambda: webbrowser.open(url)).start()
222
+
223
+ if python_reload:
224
+ if app_factory_import is None:
225
+ raise ValueError("app_factory_import is required when Python reload is enabled")
226
+ with temporary_env(dict(env_updates or {})):
227
+ run_python_reloading_server(app_factory_import, host=host, port=port)
228
+ return
229
+
230
+ if application is None:
231
+ raise ValueError("application is required when Python reload is disabled")
232
+
233
+ server = create_server(application, host=host, port=port)
234
+ run_server(server)
235
+
236
+
237
+ @app.default
238
+ def serve(
239
+ path: Annotated[
240
+ Path,
241
+ Parameter(
242
+ converter=_validate_target,
243
+ help="Markdown file or directory to serve.",
244
+ ),
245
+ ] = Path("."),
246
+ /,
247
+ *,
248
+ host: Annotated[str, Parameter(help="Host interface to bind.")] = DEFAULT_HOST,
249
+ port: Annotated[int, Parameter(help="Port to listen on.")] = DEFAULT_PORT,
250
+ open_browser: Annotated[
251
+ bool,
252
+ Parameter(name="--open", help="Open the app in your default browser after the server starts."),
253
+ ] = True,
254
+ ) -> None:
255
+ """Serve GitHub-flavored markdown from a file or directory."""
256
+ config = build_config(path)
257
+ site = build_file_site(config)
258
+ serve_application(
259
+ None if python_reload_enabled() else create_app(site),
260
+ source=str(config.source),
261
+ root_dir=str(config.root_dir),
262
+ host=host,
263
+ port=port,
264
+ open_browser=open_browser,
265
+ app_factory_import="markserv.cli:create_app_from_env",
266
+ env_updates={TARGET_ENV_VAR: str(config.source)},
267
+ )
268
+
269
+
270
+ def create_app_from_env() -> Any:
271
+ target = os.environ.get(TARGET_ENV_VAR)
272
+ if not target:
273
+ raise RuntimeError(f"{TARGET_ENV_VAR} must be set when Python reload is enabled")
274
+ config = build_config(Path(target))
275
+ return create_app(build_file_site(config))
276
+
277
+
278
+ def main(argv: list[str] | None = None) -> None:
279
+ app(tokens=argv)