Skip to content
Merged
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
19 changes: 15 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,15 @@ redirects.json ────────────────────┘
1. `nav.py` 直接提供 MkDocs 原生 nav 数据结构,不再生成或解析 YAML。
1. `index.py` 与 `contributing.py` 在 `on_page_markdown` 阶段转换页面,不写源目录副本。
1. `redirects.py` 与 `llms.py` 通过 MkDocs 1.6 虚拟文件 API 提供 `redirects.js` 和 `llms.txt`。
1. 非栅格资源由 MkDocs 直接从 `assets/` 复制;`image_pipeline.py` 并行优化 PNG/JPEG 并直接写入最终 `site/assets/`。dirty build 会跳过目标较新的图片
1. 非栅格资源由 MkDocs 直接从 `assets/` 复制;`image_pipeline.py` 并行优化 PNG/JPEG 并直接写入最终 `site/assets/`。编码结果按内容哈希缓存在 `.cache/images/`,只有源图或编码参数变化时才重新编码
1. 生产 build 的 `on_post_build` 直接把处理后的 Markdown 版本写到 `site/` 同路径,供 `/llms.txt`、“复制 Markdown”和 AI 菜单使用。

开发模式只使用 MkDocs 原生 `--dirtyreload` 与插件生命周期,没有第二套 watcher。`docs_dir` 固定为 `docs`,不允许重新引入 `cache/`、`generated/` 或生成式 `mkdocs.yml`。

**构建缓存**:`.cache/`(已 gitignore)存放 `cache.py` 约定的内容寻址产物——`images/` 放栅格编码结果,`html/` 放压缩后的页面。缓存必须位于 `site/` 之外:MkDocs 在每次非 dirty 构建前清空 `site/`,放在其中的缓存永远不可能命中。缓存目录**不能**加进 `config.watch`,否则 `dev` 每次写缓存都会触发重建。

**HTML 压缩**:`minify.py` 在 `on_post_page` 与 `on_post_template`(覆盖 `404.html`)上压缩并缓存结果。**不得换成会丢弃空属性的压缩器**(如 `minify-html`):MkDocs Material 的导航依赖 `label[tabindex]` 选择器,而主题对可折叠区块渲染的正是 `tabindex=""`,属性一旦消失,`aria-expanded` 就不再更新。

**图片管线**:`image_pipeline.py` 直接在最终输出中产出同名 `.webp` 兄弟文件;`markdown_images.py`(注册在 `mkdocs.yml`)把本地栅格图 `<img>` 改写为 WebP-first `<picture>`。外部 URL 不下载不镜像。Markdown 中仍写普通图片语法。

## Key Directories
Expand All @@ -42,6 +46,7 @@ redirects.json ────────────────────┘
| `overrides/` | mkdocs `custom_dir`:`main.html` 覆写 site_meta 移除主题版本号;`partials/actions.html` 追加 Fumadocs 风格文章操作区(复制 Markdown + GitHub / Markdown / Perplexity / Grok / ChatGPT / Claude Web / Claude Desktop / Claude Code / OpenAI Codex / Cursor 打开菜单);`.icons/ai/` 存放菜单品牌图标 |
| `scripts/` | 三平台薄启动器(`nmteam.sh` / `nmteam.ps1` / `nmteam.bat`) |
| `tests/` | pytest 行为与生命周期测试 |
| `benchmarks/` | `pytest-benchmark` 性能基准(编码参数、缓存命中、压缩开销、整构建冷/热);显式运行,**不计入** `nmteam check` |
| `mkdocs.yml` | 受版本控制的 MkDocs 单一配置源;nav 由插件在内存中设置 |
| `site/` | 唯一生成目录,勿手改勿提交 |

Expand All @@ -56,6 +61,7 @@ uv run nmteam check # 全部质量检查(见下)
uv run nmteam redirects list|add "/old/" "/new/"|remove "/old/"
uv run nmteam --help
uv run nmteam --verbose build # 显示详细 MkDocs 日志
uv run pytest benchmarks/ # 性能基准(不计入 nmteam check)
```

平台启动器(定位仓库根后原样透传参数,无业务逻辑):`scripts/nmteam.sh dev`、`.\scripts\nmteam.ps1 dev`、`scripts\nmteam.bat dev`。
Expand Down Expand Up @@ -102,12 +108,14 @@ Markdown 文档(`docs/`):
| `src/nmteam_support/nav.py` | 生成 MkDocs 原生 nav 数据结构 |
| `src/nmteam_support/contributing.py` | 非 index.md 注入贡献提示 admonition |
| `src/nmteam_support/redirects.py` | redirects.json 管理(损坏保护)+ redirects.js 生成 |
| `src/nmteam_support/image_pipeline.py` + `markdown_images.py` | 并行、增量地直写图片变体;MkDocs 扩展输出 WebP-first `<picture>` |
| `src/nmteam_support/cache.py` | 内容寻址缓存的公共约定:`.cache/` 位置、`content_digest()`(含长度前缀)、`staged_path()` 原子写入辅助 |
| `src/nmteam_support/minify.py` | `render_minified_html()`:htmlmin2 压缩页面与 HTML 模板,并按输入内容缓存;**选项必须保留属性引号与空属性** |
| `src/nmteam_support/image_pipeline.py` + `markdown_images.py` | 内容寻址地并行产出图片变体(`.cache/images/` 命中则直接复制);MkDocs 扩展输出 WebP-first `<picture>` |
| `src/nmteam_support/llms.py` | `render_llms_txt()` 从扫描树生成 `/llms.txt`(llmstxt.org 规范;链接指向各页 `.md` 版本) |
| `src/nmteam_support/models.py` | `PageMetadata`/`DocEntry` frozen dataclass |
| `pyproject.toml` | 包元数据、依赖、入口、pytest/ruff/hatchling 配置 |
| `uv.lock` | 锁定依赖(mkdocs 1.6.1、mkdocs-material 9.7.7、mkdocs-minify-plugin 0.8.0、pillow 12.3.0、typer 0.27.1、pytest 9.1.1、ruff 0.16.2、mdformat 1.0.0、mdformat-footnote 0.1.3 等) |
| `mkdocs.yml` | MkDocs 单一配置源(direct docs_dir、nmteam-support、Material、minify、Markdown 扩展) |
| `uv.lock` | 锁定依赖(mkdocs 1.6.1、mkdocs-material 9.7.7、htmlmin2 0.1.13、pillow 12.3.0、typer 0.27.1、pytest 9.1.1、ruff 0.16.2、mdformat 1.0.0、mdformat-footnote 0.1.3 等) |
| `mkdocs.yml` | MkDocs 单一配置源(direct docs_dir、nmteam-support、Material、Markdown 扩展) |
| `redirects.json` | 顶层 `redirects` 对象:`{旧路径带斜杠: 新路径}` |
| `.github/workflows/ci.yml` | 三 OS 矩阵 CI(push main/dev + PR):uv sync --frozen → nmteam check → 验证三个启动器 |
| `.mdformat.toml` | mdformat 配置(wrap=keep、LF) |
Expand All @@ -132,4 +140,7 @@ Markdown 文档(`docs/`):
- 输入构造分级:conftest 的 `docs_dir` fixture(tmp_path 构造最小 docs 树)→ 插件事件测试 → 真实 MkDocs 生命周期集成。**不依赖真实 docs/ 内容**。
- CLI 测试用 `typer.testing.CliRunner` + monkeypatch;启动器测试用 subprocess + 假 uv 脚本。
- **无覆盖率门槛**(无 pytest-cov、CI 无 coverage 步骤)——新增功能时给模块补 `test_<module>.py` 行为测试即可。
- **性能基准**在 `benchmarks/`(`pytest-benchmark`),**不进入** `nmteam check`:`testpaths = ["tests"]` 将它隔离,只有显式 `uv run pytest benchmarks/` 才跑。基准必须针对仓库真实资源而非合成输入;新增可调参数时同时补一个 `benchmark.pedantic(setup=...)` 场景——测试体只会执行一次,需要每轮重置的状态必须放 `setup`。
- **改动构建产物时先做字节级回归**:拿 `main` 开一个干净 worktree,两边各构建一次,逐文件比 SHA-256;除有意变更(例如 WebP 档位)外应当全等。这类对比能揭出测试覆盖不到的「某类产物被漏处理」;`404.html` 漏压缩就是这么发现的。
- **基准表里写倍率,不写含糊的百分比**:`旧 ÷ 新` 得到的是「是原来的 X%」,不是「提升 X%」(4.41 s → 2.33 s 是**快 1.89 倍 / 耗时 -47.2%**,写成「提升 189%」会被当成算错)。min 只对 min、mean 只对 mean;数字要注明测量方式,因为跑真实 CLI 与进程内测量能差出解释器启动那 0.2s。
- CI 在 ubuntu/macos/windows 三平台跑全量 check;提交前本地至少跑 `uv run nmteam check`。
32 changes: 28 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ uv run nmteam build
构建结果输出到 `site/` 目录。

MkDocs 插件会在一次构建中完成动态导航、目录页、贡献提示、`llms.txt`、
重定向脚本和图片优化。图片直接并行写入 `site/`,不会创建 `cache/` 或
`generated/` 副本。
重定向脚本、HTML 压缩和图片优化。图片与压缩后的 HTML 存放在 `.cache/`
(可随时删除,仅会多付一次冷构建),不会创建 `cache/` 或 `generated/`
副本。

最终 HTML 会由 `mkdocs-minify-plugin` 压缩;生成器元标签仅保留 MkDocs
最终 HTML 由插件自行压缩并缓存;生成器元标签仅保留 MkDocs
版本,不暴露主题及其版本。

## 静态资源
Expand All @@ -54,7 +55,7 @@ MkDocs 插件会在一次构建中完成动态导航、目录页、贡献提示
文档使用 `/assets/...` 引用这些资源。生成文档时,每张 PNG 或 JPEG
图片会同时产生:

- WebP 优先版本:质量 80
- WebP 优先版本:质量 80,编码 effort 5
- 原格式 fallback:JPEG 使用质量 80;PNG 使用 256 色有损量化

Markdown 中仍使用普通图片语法,构建工具会自动输出 WebP
Expand Down Expand Up @@ -134,6 +135,29 @@ uv run nmteam build

以上检查由 CI(`.github/workflows/ci.yml`)自动执行。

## 性能基准

构建性能的测量在 `benchmarks/`,使用 `pytest-benchmark`。它们不计入
`pytest` 与 `nmteam check`(后者只看 `tests/`),需要显式运行:

```bash
uv run pytest benchmarks/
uv run pytest benchmarks/ --benchmark-sort=name
uv run pytest benchmarks/test_bench_image_pipeline.py -k method
```

与保存的基线对比,用于判断一次改动是否真的更快:

```bash
uv run pytest benchmarks/ --benchmark-json=/tmp/base.json
# 改动之后
uv run pytest benchmarks/ --benchmark-compare=/tmp/base.json --benchmark-compare-fail=min:5%
```

`test_bench_build.py` 在独立进程中运行真实的 `nmteam build`,因此也把
解释器启动计入结果;其余文件在进程内测量编码参数、缓存命中路径与
压缩开销。所有基准都针对仓库自身的真实资源,而不是合成输入。

## 重定向管理

```bash
Expand Down
66 changes: 66 additions & 0 deletions benchmarks/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Shared fixtures for the performance suite.

These benchmarks intentionally run against the real repository: the encoder settings,
cache layout and minifier choices they compare were made for this site's actual mix of
screenshots, diagrams and rendered pages, so synthetic inputs would measure the wrong
work.
"""

from __future__ import annotations

from pathlib import Path

import pytest
from mkdocs.commands.build import build
from mkdocs.config import load_config

from nmteam_support import plugin as plugin_module
from nmteam_support.image_pipeline import RASTER_SUFFIXES

REPO_ROOT = Path(__file__).resolve().parents[1]


@pytest.fixture(scope="session")
def repo_root() -> Path:
return REPO_ROOT


@pytest.fixture(scope="session")
def assets_dir() -> Path:
return REPO_ROOT / "assets"


@pytest.fixture(scope="session")
def raster_paths() -> list[Path]:
assets = REPO_ROOT / "assets"
return [
path
for path in sorted(assets.rglob("*"))
if path.is_file() and path.suffix.lower() in RASTER_SUFFIXES
]


@pytest.fixture(scope="session")
def raster_payloads(raster_paths: list[Path]) -> list[bytes]:
"""Every raster's bytes, read once so no benchmark measures filesystem reads."""
return [path.read_bytes() for path in raster_paths]


@pytest.fixture(scope="session")
def page_html(tmp_path_factory: pytest.TempPathFactory) -> list[str]:
"""The HTML the site hands to the minifier, captured from a real build.

The build runs with the minify hook replaced by the identity function, so these are
the minifier's inputs rather than its outputs.
"""
site_dir = tmp_path_factory.mktemp("unminified-site")
config = load_config(config_file=str(REPO_ROOT / "mkdocs.yml"), strict=True)
config.site_dir = str(site_dir)
original = plugin_module.render_minified_html
plugin_module.render_minified_html = lambda html, *, cache_dir: html
try:
config.plugins.on_startup(command="build", dirty=False)
build(config)
finally:
plugin_module.render_minified_html = original
return [path.read_text(encoding="utf-8") for path in sorted(site_dir.rglob("*.html"))]
38 changes: 38 additions & 0 deletions benchmarks/test_bench_build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""End-to-end timings for the shipped command.

Unlike the other benchmarks these measure the real entry point in its own process, so
the numbers include interpreter start-up and are directly comparable to what a developer
sees. They build into the repository's own ``site/`` and reuse its ``.cache/``.
"""

from __future__ import annotations

import shutil
import subprocess
import sys
from pathlib import Path


def _build(repo_root: Path) -> None:
subprocess.run(
[sys.executable, "-m", "nmteam_support", "build"],
cwd=repo_root,
capture_output=True,
check=True,
)


def test_build_cold_cache(benchmark, repo_root: Path):
"""A clean checkout, or CI: every raster is encoded and every page minified."""
benchmark.pedantic(
lambda: _build(repo_root),
setup=lambda: shutil.rmtree(repo_root / ".cache", ignore_errors=True),
rounds=3,
iterations=1,
)


def test_build_warm_cache(benchmark, repo_root: Path):
"""The common local loop, where only the edited page has changed."""
_build(repo_root)
benchmark.pedantic(lambda: _build(repo_root), rounds=5, iterations=1)
35 changes: 35 additions & 0 deletions benchmarks/test_bench_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Cache addressing and the cost of the warm publish path."""

from __future__ import annotations

import shutil
from pathlib import Path

from nmteam_support.cache import content_digest
from nmteam_support.image_pipeline import optimize_assets


def test_content_digest_over_every_raster(benchmark, raster_payloads: list[bytes]):
"""Hashing is what a warm build pays for every asset it does not re-encode."""
benchmark(lambda: [content_digest(b"image", payload) for payload in raster_payloads])


def test_content_digest_over_a_rendered_page(benchmark, page_html: list[str]):
benchmark(lambda: [content_digest(b"html", html.encode("utf-8")) for html in page_html])


def test_warm_publish_path(benchmark, assets_dir: Path, tmp_path: Path):
"""A warm build: hash every raster, then copy the cached variants into ``site/``."""
target = tmp_path / "site"
cache_dir = tmp_path / "cache"
optimize_assets(assets_dir, target, cache_dir=cache_dir)

def wipe_site() -> None:
shutil.rmtree(target, ignore_errors=True)

benchmark.pedantic(
lambda: optimize_assets(assets_dir, target, cache_dir=cache_dir),
setup=wipe_site,
rounds=5,
iterations=1,
)
44 changes: 44 additions & 0 deletions benchmarks/test_bench_image_pipeline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Encoder effort and concurrency, measured on the site's real rasters."""

from __future__ import annotations

import shutil
from pathlib import Path

import pytest

import nmteam_support.image_pipeline as image_pipeline


@pytest.mark.parametrize("method", [4, 5, 6])
def test_cold_encode_pass_by_webp_method(
benchmark, monkeypatch, assets_dir: Path, tmp_path: Path, method: int
):
"""What ``WEBP_METHOD`` buys: encode effort against bytes and wall clock."""
cache_dir = tmp_path / f"cache-m{method}"
target = tmp_path / f"site-m{method}"
monkeypatch.setattr(image_pipeline, "WEBP_METHOD", method)

benchmark.pedantic(
lambda: image_pipeline.optimize_assets(assets_dir, target, cache_dir=cache_dir),
setup=lambda: shutil.rmtree(cache_dir, ignore_errors=True),
rounds=3,
iterations=1,
)


@pytest.mark.parametrize("workers", [1, 4, 8])
def test_cold_encode_pass_by_worker_count(
benchmark, monkeypatch, assets_dir: Path, tmp_path: Path, workers: int
):
"""Whether the thread pool actually scales: Pillow's encoder releases the GIL."""
cache_dir = tmp_path / f"cache-w{workers}"
target = tmp_path / f"site-w{workers}"
monkeypatch.setattr(image_pipeline, "MAX_IMAGE_WORKERS", workers)

benchmark.pedantic(
lambda: image_pipeline.optimize_assets(assets_dir, target, cache_dir=cache_dir),
setup=lambda: shutil.rmtree(cache_dir, ignore_errors=True),
rounds=3,
iterations=1,
)
Loading