Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ Markdown 中仍使用普通图片语法,构建工具会自动输出 WebP
每个链接指向页面的 Markdown 版本。
- 每页 Markdown 版本:`nmteam build` 时在每个页面旁生成同路径的 `.md` 文件
(如 `/nmbot-telegram/mcp.md`)。
- 页面顶部的文章操作区:**复制本文 Markdown** 直接复制当前页面原文;
**使用[品牌图标]打开本文** 菜单提供 GitHub 源文件、Markdown 版本,
- 页面顶部的文章操作区(首页不显示):**复制 Markdown** 直接复制当前页面
原文;**使用[品牌图标]打开** 菜单提供 GitHub 源文件、Markdown 版本,
以及 Perplexity、Grok、ChatGPT、Claude Web、Claude Desktop、
Claude Code、OpenAI Codex、Cursor 八种 AI 打开方式。

Expand All @@ -83,9 +83,16 @@ Markdown 中仍使用普通图片语法,构建工具会自动输出 WebP
```bash
uv run nmteam generate # 仅重新生成文档结构
uv run nmteam clean # 清理 cache/、generated/ 和 site/
uv run nmteam serve # 预览 site/(.md 以 text/plain; charset=utf-8 提供)
uv run nmteam --help # 显示完整命令帮助
```

`nmteam serve` 默认监听 `127.0.0.1:8124`,可用 `--port` / `--bind`
调整。与裸 `python -m http.server` 不同,它把 `.md` 文件的
`Content-Type` 显式设为 `text/plain; charset=utf-8`(Python 3.13+ 的
`mimetypes` 会把 `.md` 判为 `text/markdown`,部分客户端会下载而非内联
显示),保证每页 Markdown 版本在任何浏览器中都能直接阅读。

## 平台启动器

直接运行 `uv run nmteam` 是推荐方式。`scripts/` 也提供不包含业务逻辑的薄启动器;它们会自动定位仓库根目录并原样传递参数。
Expand Down
14 changes: 14 additions & 0 deletions src/nmteam_support/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
read_redirects,
write_redirects,
)
from nmteam_support.serve import DEFAULT_BIND, DEFAULT_PORT, serve_site

app = typer.Typer(invoke_without_command=True)
redirects_app = typer.Typer(no_args_is_help=True)
Expand Down Expand Up @@ -182,6 +183,19 @@ def install_command() -> None:
_exit_on_error(cmd_install())


@app.command("serve")
def serve_command(
port: int = typer.Option(DEFAULT_PORT, "--port", "-p", help="TCP port to listen on."),
bind: str = typer.Option(DEFAULT_BIND, "--bind", "-b", help="Address to bind to."),
) -> None:
"""Serve the built site; Markdown is served as text/plain (UTF-8)."""
site_dir = default_options().mkdocs_yml_path.parent / "site"
if not site_dir.is_dir():
typer.echo("site/ 不存在,请先运行 `nmteam build`。", err=True)
raise typer.Exit(code=1)
serve_site(site_dir, port, bind)


@app.command("check")
def check_command() -> None:
"""Run linting, tests, formatting checks, and a strict build."""
Expand Down
49 changes: 49 additions & 0 deletions src/nmteam_support/serve.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Static file server that serves Markdown as ``text/plain; charset=utf-8``.

Standard-library only, so ``nmteam serve`` works on every platform without
extra dependencies. The MIME override matters because Python's ``mimetypes``
module maps ``.md`` to ``text/markdown`` on 3.13+, which some browsers and
clients download or render inconsistently; ``text/plain`` with an explicit
UTF-8 charset is the most interoperable way to expose the per-page Markdown
copies staged into ``site/``.
"""

from __future__ import annotations

from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import ClassVar

DEFAULT_PORT = 8124
DEFAULT_BIND = "127.0.0.1"


class MarkdownPlainHandler(SimpleHTTPRequestHandler):
"""Serve ``.md``/``.markdown`` files as plain UTF-8 text."""

extensions_map: ClassVar[dict[str, str]] = {
**SimpleHTTPRequestHandler.extensions_map,
".md": "text/plain; charset=utf-8",
".markdown": "text/plain; charset=utf-8",
}


def create_server(
directory: Path, port: int = DEFAULT_PORT, bind: str = DEFAULT_BIND
) -> ThreadingHTTPServer:
"""Build a threaded HTTP server rooted at ``directory``."""
resolved = directory.resolve()
handler = partial(MarkdownPlainHandler, directory=str(resolved))
return ThreadingHTTPServer((bind, port), handler)


def serve_site(directory: Path, port: int = DEFAULT_PORT, bind: str = DEFAULT_BIND) -> None:
"""Serve ``directory`` statically until interrupted."""
server = create_server(directory, port, bind)
host, bound_port = server.server_address
print(f"Serving {directory.resolve()} at http://{host}:{bound_port}")
try:
server.serve_forever()
finally:
server.server_close()
27 changes: 27 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,33 @@ def test_redirects_list_displays_existing_rules(tmp_path, monkeypatch):
assert "/old/ -> /new/" in result.stdout


def test_serve_command_serves_site_with_defaults(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "site").mkdir()
captured = {}
monkeypatch.setattr(
cli,
"serve_site",
lambda directory, port, bind: captured.update(directory=directory, port=port, bind=bind),
)

result = runner.invoke(cli.app, ["serve"])

assert result.exit_code == 0
assert captured["directory"] == tmp_path / "site"
assert captured["port"] == 8124
assert captured["bind"] == "127.0.0.1"


def test_serve_command_rejects_missing_site(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)

result = runner.invoke(cli.app, ["serve"])

assert result.exit_code != 0
assert "site/ 不存在" in result.stderr


def test_redirects_remove_deletes_existing_rule(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "redirects.json").write_text(
Expand Down
51 changes: 51 additions & 0 deletions tests/test_serve.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Static serve tests: Markdown is served as text/plain with UTF-8 charset."""

import threading
import urllib.request
from unittest.mock import Mock

import pytest

import nmteam_support.serve as serve


def _base_url(server) -> str:
host, port = server.server_address
return f"http://{host}:{port}"


def test_markdown_served_as_text_plain_utf8(tmp_path):
(tmp_path / "page.md").write_text("# 标题\n", encoding="utf-8", newline="\n")
(tmp_path / "index.html").write_text("<h1>hi</h1>", encoding="utf-8")

server = serve.create_server(tmp_path, port=0)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
base = _base_url(server)
with urllib.request.urlopen(f"{base}/page.md", timeout=5) as response:
assert response.headers.get_content_type() == "text/plain"
assert response.headers.get_content_charset() == "utf-8"
assert response.read().decode("utf-8") == "# 标题\n"
with urllib.request.urlopen(f"{base}/index.html", timeout=5) as response:
assert response.headers.get_content_type() == "text/html"
finally:
server.shutdown()
server.server_close()


def test_markdown_extension_map_excludes_text_markdown():
for ext in (".md", ".markdown"):
assert serve.MarkdownPlainHandler.extensions_map[ext] == ("text/plain; charset=utf-8")
assert "text/markdown" not in serve.MarkdownPlainHandler.extensions_map.values()


def test_serve_site_closes_server_and_propagates_interrupt(tmp_path, monkeypatch):
server = Mock(server_address=("127.0.0.1", 8124))
server.serve_forever.side_effect = KeyboardInterrupt
monkeypatch.setattr(serve, "create_server", lambda *_args: server)

with pytest.raises(KeyboardInterrupt):
serve.serve_site(tmp_path)

server.server_close.assert_called_once_with()