diff --git a/README.md b/README.md index 08b54b1..63372f6 100644 --- a/README.md +++ b/README.md @@ -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 打开方式。 @@ -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/` 也提供不包含业务逻辑的薄启动器;它们会自动定位仓库根目录并原样传递参数。 diff --git a/src/nmteam_support/cli.py b/src/nmteam_support/cli.py index 37d0e1f..e832c12 100644 --- a/src/nmteam_support/cli.py +++ b/src/nmteam_support/cli.py @@ -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) @@ -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.""" diff --git a/src/nmteam_support/serve.py b/src/nmteam_support/serve.py new file mode 100644 index 0000000..bfd9ac0 --- /dev/null +++ b/src/nmteam_support/serve.py @@ -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() diff --git a/tests/test_cli.py b/tests/test_cli.py index a65d4b6..7a88993 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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( diff --git a/tests/test_serve.py b/tests/test_serve.py new file mode 100644 index 0000000..d322952 --- /dev/null +++ b/tests/test_serve.py @@ -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("

hi

", 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()