diff --git a/AGENTS.md b/AGENTS.md
index b59d9ac..0dcfdf4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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`)把本地栅格图 `
` 改写为 WebP-first ``。外部 URL 不下载不镜像。Markdown 中仍写普通图片语法。
## Key Directories
@@ -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/` | 唯一生成目录,勿手改勿提交 |
@@ -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`。
@@ -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 `` |
+| `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 `` |
| `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) |
@@ -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_.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`。
diff --git a/README.md b/README.md
index fea8b78..b9b2d5a 100644
--- a/README.md
+++ b/README.md
@@ -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
版本,不暴露主题及其版本。
## 静态资源
@@ -54,7 +55,7 @@ MkDocs 插件会在一次构建中完成动态导航、目录页、贡献提示
文档使用 `/assets/...` 引用这些资源。生成文档时,每张 PNG 或 JPEG
图片会同时产生:
-- WebP 优先版本:质量 80
+- WebP 优先版本:质量 80,编码 effort 5
- 原格式 fallback:JPEG 使用质量 80;PNG 使用 256 色有损量化
Markdown 中仍使用普通图片语法,构建工具会自动输出 WebP
@@ -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
diff --git a/benchmarks/conftest.py b/benchmarks/conftest.py
new file mode 100644
index 0000000..8ec4515
--- /dev/null
+++ b/benchmarks/conftest.py
@@ -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"))]
diff --git a/benchmarks/test_bench_build.py b/benchmarks/test_bench_build.py
new file mode 100644
index 0000000..78c5795
--- /dev/null
+++ b/benchmarks/test_bench_build.py
@@ -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)
diff --git a/benchmarks/test_bench_cache.py b/benchmarks/test_bench_cache.py
new file mode 100644
index 0000000..9aa7a3f
--- /dev/null
+++ b/benchmarks/test_bench_cache.py
@@ -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,
+ )
diff --git a/benchmarks/test_bench_image_pipeline.py b/benchmarks/test_bench_image_pipeline.py
new file mode 100644
index 0000000..2c7f0e7
--- /dev/null
+++ b/benchmarks/test_bench_image_pipeline.py
@@ -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,
+ )
diff --git a/benchmarks/test_bench_minify.py b/benchmarks/test_bench_minify.py
new file mode 100644
index 0000000..e06d7d1
--- /dev/null
+++ b/benchmarks/test_bench_minify.py
@@ -0,0 +1,42 @@
+"""Minification cost, cold against cached."""
+
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+
+from nmteam_support.minify import render_minified_html
+
+
+def test_minify_every_page_cold(benchmark, page_html: list[str], tmp_path: Path):
+ """The full-price pass, which is what a clean checkout or CI pays."""
+ cache_dir = tmp_path / "cache"
+
+ benchmark.pedantic(
+ lambda: [render_minified_html(html, cache_dir=cache_dir) for html in page_html],
+ setup=lambda: shutil.rmtree(cache_dir, ignore_errors=True),
+ rounds=3,
+ iterations=1,
+ )
+
+
+def test_minify_every_page_warm(benchmark, page_html: list[str], tmp_path: Path):
+ """The same pass once the cache holds every page, which is the common local build."""
+ cache_dir = tmp_path / "cache"
+ for html in page_html:
+ render_minified_html(html, cache_dir=cache_dir)
+
+ benchmark(lambda: [render_minified_html(html, cache_dir=cache_dir) for html in page_html])
+
+
+def test_minify_single_page_cold(benchmark, page_html: list[str], tmp_path: Path):
+ """One page re-rendered: the only work a single-page edit should ever repeat."""
+ cache_dir = tmp_path / "cache"
+ page = max(page_html, key=len)
+
+ benchmark.pedantic(
+ lambda: render_minified_html(page, cache_dir=cache_dir),
+ setup=lambda: shutil.rmtree(cache_dir, ignore_errors=True),
+ rounds=5,
+ iterations=1,
+ )
diff --git a/mkdocs.yml b/mkdocs.yml
index 3b79975..69ff5b0 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -3,12 +3,6 @@ site_url: https://support.nmteam.xyz
plugins:
- nmteam-support
- search
- - minify:
- minify_html: true
- htmlmin_opts:
- remove_comments: true
- remove_optional_attribute_quotes: false
- reduce_empty_attributes: false
theme:
name: material
custom_dir: overrides
diff --git a/pyproject.toml b/pyproject.toml
index a2f2089..9017ec2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,10 +4,10 @@ version = "0.1.0"
description = "nmTeam Support 文档站构建工具链"
requires-python = ">=3.14"
dependencies = [
+ "htmlmin2>=0.1.13",
"markdown>=3.10",
"mkdocs==1.6.1",
"mkdocs-material==9.7.7",
- "mkdocs-minify-plugin>=0.8.0",
"pillow>=12.1.0",
"pyyaml>=6.0.2",
"typer>=0.21.1",
@@ -26,6 +26,7 @@ dev = [
"mdformat-front-matters>=2.0",
"mdformat-mkdocs>=5.2.2",
"pytest>=9.0",
+ "pytest-benchmark>=5.3.0",
"ruff>=0.16",
]
diff --git a/src/nmteam_support/cache.py b/src/nmteam_support/cache.py
new file mode 100644
index 0000000..e415944
--- /dev/null
+++ b/src/nmteam_support/cache.py
@@ -0,0 +1,39 @@
+"""Content-addressed cache shared by the build's expensive, deterministic transforms."""
+
+from __future__ import annotations
+
+import os
+from hashlib import blake2b
+from pathlib import Path
+
+# Cache root, relative to the project root. It deliberately lives outside ``site/``:
+# MkDocs wipes the site directory on every non-dirty build, so a cache placed there
+# would never survive long enough to be hit. The directory is disposable — deleting it
+# only costs one cold build.
+CACHE_DIR_NAME = ".cache"
+
+_DIGEST_SIZE = 20
+_LENGTH_SIZE = 8
+
+
+def content_digest(*parts: bytes) -> str:
+ """Address one cache entry by the exact inputs that determine its output.
+
+ Callers must pass everything the output depends on, including the parameters of
+ the transform, so that tuning it invalidates the entries produced by the old one.
+ Parts are length-prefixed, so splitting an input differently cannot collide.
+ """
+ digest = blake2b(digest_size=_DIGEST_SIZE)
+ for part in parts:
+ digest.update(len(part).to_bytes(_LENGTH_SIZE, "big"))
+ digest.update(part)
+ return digest.hexdigest()
+
+
+def staged_path(path: Path) -> Path:
+ """A process-unique sibling used to publish a cache entry without partial content.
+
+ Two builds of this checkout can overlap, so entries are written to a private name
+ and moved into place, making a half-written file impossible to observe.
+ """
+ return path.with_name(f"{path.name}.{os.getpid()}.part")
diff --git a/src/nmteam_support/image_pipeline.py b/src/nmteam_support/image_pipeline.py
index 71cc074..14f18ce 100644
--- a/src/nmteam_support/image_pipeline.py
+++ b/src/nmteam_support/image_pipeline.py
@@ -1,54 +1,115 @@
-"""Direct-to-site raster optimization."""
+"""Content-addressed raster optimization with a persistent build cache."""
from __future__ import annotations
import os
+import shutil
from concurrent.futures import ThreadPoolExecutor
+from dataclasses import dataclass
from pathlib import Path
from PIL import Image
+from nmteam_support.cache import content_digest, staged_path
+
RASTER_QUALITY = 80
RASTER_SUFFIXES = frozenset({".jpg", ".jpeg", ".png"})
-MAX_IMAGE_WORKERS = 4
+PNG_COLORS = 256
+
+# Pillow's WebP ``method`` trades encoding effort for a few percent of bytes. Measured on
+# this repository's rasters, dropping from ``6`` to ``5`` costs 1.7% more bytes and takes
+# roughly a quarter of the encode time — see ``benchmarks/test_bench_image_pipeline.py``.
+WEBP_METHOD = 5
+
+# Encoding runs in Pillow's C encoder, which releases the GIL, so threads scale with cores.
+# The same benchmark measures the full pass at 2.7s / 1.0s / 0.7s for 1 / 4 / 8 workers.
+MAX_IMAGE_WORKERS = 8
+
+# Bump when the encoder pipeline changes in a way ``_ENCODER`` cannot express, otherwise
+# the parameters below would keep matching stale cache entries.
+CACHE_VERSION = 1
+
+_ENCODER = (
+ f"v{CACHE_VERSION}|webp:{RASTER_QUALITY}:{WEBP_METHOD}"
+ f"|png:{PNG_COLORS}|jpeg:{RASTER_QUALITY}:progressive"
+).encode()
+
+
+@dataclass(frozen=True)
+class RasterJob:
+ """One source raster plus the cache and output paths derived from its content."""
+
+ source: Path
+ target: Path
+ cache_dir: Path
+ digest: str
+
+ @property
+ def outputs(self) -> tuple[tuple[Path, Path], ...]:
+ """``(cached, published)`` pairs: WebP first, original-format fallback second."""
+ return (
+ (self.cache_dir / f"{self.digest}.webp", self.target.with_suffix(".webp")),
+ (self.cache_dir / f"{self.digest}{self.target.suffix.lower()}", self.target),
+ )
+
+
+def optimize_assets(source_dir: Path, target_dir: Path, *, cache_dir: Path) -> int:
+ """Publish optimized rasters into ``target_dir`` and return how many were encoded.
+
+ A raster is only encoded when its cache entry is missing, so a warm build is a
+ content hash plus a file copy per asset. The return value lets callers and tests
+ tell a cold build from a warm one.
+ """
+ jobs = _collect(source_dir, target_dir, cache_dir)
+ misses = [job for job in jobs if not _is_cached(job)]
+ if misses:
+ with ThreadPoolExecutor(max_workers=_worker_count(len(misses))) as executor:
+ list(executor.map(_encode, misses))
+ for job in jobs:
+ for cached, published in job.outputs:
+ published.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(cached, published)
+ return len(misses)
-def optimize_assets(source_dir: Path, target_dir: Path, *, incremental: bool = False) -> int:
- """Write raster variants directly to ``target_dir`` and return the processed count."""
- tasks = [
- (source, target_dir / source.relative_to(source_dir))
+def _collect(source_dir: Path, target_dir: Path, cache_dir: Path) -> list[RasterJob]:
+ return [
+ RasterJob(
+ source=source,
+ target=target_dir / source.relative_to(source_dir),
+ cache_dir=cache_dir,
+ digest=_digest(source),
+ )
for source in sorted(source_dir.rglob("*"))
if source.is_file() and source.suffix.lower() in RASTER_SUFFIXES
]
- if incremental:
- tasks = [(source, target) for source, target in tasks if _is_stale(source, target)]
- if not tasks:
- return 0
- worker_count = min(MAX_IMAGE_WORKERS, len(tasks), os.cpu_count() or 1)
- with ThreadPoolExecutor(max_workers=worker_count) as executor:
- list(executor.map(lambda paths: _optimize_raster(*paths), tasks))
- return len(tasks)
-
-
-def _is_stale(source: Path, fallback: Path) -> bool:
- webp = fallback.with_suffix(".webp")
- if not fallback.exists() or not webp.exists():
- return True
- source_mtime = source.stat().st_mtime_ns
- return fallback.stat().st_mtime_ns < source_mtime or webp.stat().st_mtime_ns < source_mtime
-
-
-def _optimize_raster(source: Path, fallback: Path) -> None:
- fallback.parent.mkdir(parents=True, exist_ok=True)
- with Image.open(source) as image:
+
+
+def _digest(source: Path) -> str:
+ """Content address over the source bytes and the encoder parameters."""
+ return content_digest(_ENCODER, source.read_bytes())
+
+
+def _is_cached(job: RasterJob) -> bool:
+ return all(cached.is_file() for cached, _ in job.outputs)
+
+
+def _worker_count(pending: int) -> int:
+ return max(1, min(MAX_IMAGE_WORKERS, pending, (os.cpu_count() or 1) - 1))
+
+
+def _encode(job: RasterJob) -> None:
+ """Encode one source into the cache; published files are always copies of it."""
+ job.cache_dir.mkdir(parents=True, exist_ok=True)
+ webp, fallback = (cached for cached, _ in job.outputs)
+ with Image.open(job.source) as image:
image.load()
- webp = fallback.with_suffix(".webp")
- image.save(webp, "WEBP", quality=RASTER_QUALITY, method=6)
- if fallback.suffix.lower() == ".png":
- _quantize_png(image).save(fallback, "PNG", optimize=True)
+ _save(image, webp, "WEBP", quality=RASTER_QUALITY, method=WEBP_METHOD)
+ if fallback.suffix == ".png":
+ _save(_quantize_png(image), fallback, "PNG", optimize=True)
else:
- jpeg = image if image.mode in {"RGB", "L", "CMYK"} else image.convert("RGB")
- jpeg.save(
+ _save(
+ _jpeg_ready(image),
fallback,
"JPEG",
quality=RASTER_QUALITY,
@@ -57,13 +118,24 @@ def _optimize_raster(source: Path, fallback: Path) -> None:
)
+def _save(image: Image.Image, path: Path, image_format: str, **options: bool | int) -> None:
+ """Stage through a process-unique file so a parallel build never reads a partial image."""
+ staged = staged_path(path)
+ image.save(staged, image_format, **options)
+ staged.replace(path)
+
+
+def _jpeg_ready(image: Image.Image) -> Image.Image:
+ return image if image.mode in {"RGB", "L", "CMYK"} else image.convert("RGB")
+
+
def _quantize_png(image: Image.Image) -> Image.Image:
if image.mode in {"RGBA", "LA"} or "transparency" in image.info:
return image.convert("RGBA").quantize(
- colors=256,
+ colors=PNG_COLORS,
method=Image.Quantize.FASTOCTREE,
)
return image.convert("RGB").quantize(
- colors=256,
+ colors=PNG_COLORS,
method=Image.Quantize.MEDIANCUT,
)
diff --git a/src/nmteam_support/minify.py b/src/nmteam_support/minify.py
new file mode 100644
index 0000000..0f5cf48
--- /dev/null
+++ b/src/nmteam_support/minify.py
@@ -0,0 +1,45 @@
+"""HTML minification of rendered pages, cached by the exact input that produced it."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import htmlmin
+
+from nmteam_support.cache import content_digest, staged_path
+
+# The options the site has always minified with. Quoting and empty attributes are
+# deliberately preserved: MkDocs Material's navigation binds its behaviour to
+# ``label[tabindex]``, so a minifier that drops empty attributes would silently break it.
+_OPTIONS: dict[str, bool] = {
+ "remove_comments": True,
+ "remove_optional_attribute_quotes": False,
+ "reduce_empty_attributes": False,
+}
+
+# Bump when the minifier output changes in a way ``_OPTIONS`` cannot express.
+_CACHE_VERSION = 1
+_MINIFIER = f"v{_CACHE_VERSION}|htmlmin2|{sorted(_OPTIONS.items())}".encode()
+
+
+def render_minified_html(html: str, *, cache_dir: Path) -> str:
+ """Minify one rendered page, reusing the cached result of an identical input.
+
+ htmlmin is pure Python and costs about 0.7s for the whole site, while the pages it
+ receives repeat byte for byte across builds whenever the documentation tree is
+ unchanged — see ``benchmarks/test_bench_minify.py``.
+ """
+ digest = content_digest(_MINIFIER, html.encode("utf-8"))
+ cached = cache_dir / digest
+ if cached.is_file():
+ return cached.read_text(encoding="utf-8")
+ minified = _minify(html)
+ cache_dir.mkdir(parents=True, exist_ok=True)
+ staged = staged_path(cached)
+ staged.write_text(minified, encoding="utf-8", newline="\n")
+ staged.replace(cached)
+ return minified
+
+
+def _minify(html: str) -> str:
+ return htmlmin.minify(html, **_OPTIONS)
diff --git a/src/nmteam_support/plugin.py b/src/nmteam_support/plugin.py
index ef7caee..0dda8bd 100644
--- a/src/nmteam_support/plugin.py
+++ b/src/nmteam_support/plugin.py
@@ -11,6 +11,7 @@
from mkdocs.structure.files import File, Files
from mkdocs.structure.pages import Page
+from nmteam_support.cache import CACHE_DIR_NAME
from nmteam_support.contributing import (
render_doc_body,
render_doc_file,
@@ -19,6 +20,7 @@
from nmteam_support.image_pipeline import RASTER_SUFFIXES, optimize_assets
from nmteam_support.index import render_index_body, render_index_page
from nmteam_support.llms import render_llms_txt
+from nmteam_support.minify import render_minified_html
from nmteam_support.models import DocEntry
from nmteam_support.nav import build_nav, is_renderable
from nmteam_support.redirects import read_redirects, render_redirects_js
@@ -37,11 +39,10 @@ def __init__(self) -> None:
self._directories: dict[str, ScannedDir] = {}
self._entries: dict[str, DocEntry] = {}
self._command: Literal["build", "gh-deploy", "serve"] = "build"
- self._dirty = False
def on_startup(self, *, command: Literal["build", "gh-deploy", "serve"], dirty: bool) -> None:
+ del dirty
self._command = command
- self._dirty = dirty
def on_config(self, config: MkDocsConfig) -> MkDocsConfig:
root = _project_root(config)
@@ -122,6 +123,17 @@ def on_page_markdown(
return markdown
return render_doc_body(markdown, path)
+ def on_post_page(self, output: str, /, *, page: Page, config: MkDocsConfig) -> str:
+ del page
+ return render_minified_html(output, cache_dir=_cache_dir(config, "html"))
+
+ def on_post_template(
+ self, output_content: str, /, *, template_name: str, config: MkDocsConfig
+ ) -> str:
+ if not template_name.endswith(".html"):
+ return output_content
+ return render_minified_html(output_content, cache_dir=_cache_dir(config, "html"))
+
def on_post_build(self, *, config: MkDocsConfig) -> None:
root = _project_root(config)
assets_dir = root / "assets"
@@ -129,7 +141,7 @@ def on_post_build(self, *, config: MkDocsConfig) -> None:
optimize_assets(
assets_dir,
Path(config.site_dir) / "assets",
- incremental=self._dirty,
+ cache_dir=_cache_dir(config, "images"),
)
if self._command != "serve" and self._catalog is not None:
_write_markdown_copies(self._catalog, self._directories, self._entries, config)
@@ -141,6 +153,10 @@ def _project_root(config: MkDocsConfig) -> Path:
return Path.cwd()
+def _cache_dir(config: MkDocsConfig, namespace: str) -> Path:
+ return _project_root(config) / CACHE_DIR_NAME / namespace
+
+
def _directory_map(root: ScannedDir) -> dict[str, ScannedDir]:
result: dict[str, ScannedDir] = {}
diff --git a/tests/test_build_config.py b/tests/test_build_config.py
index 7388e49..7bc06f5 100644
--- a/tests/test_build_config.py
+++ b/tests/test_build_config.py
@@ -1,29 +1,12 @@
-"""Production HTML minification configuration tests."""
+"""Production build configuration tests."""
from pathlib import Path
-import htmlmin
import yaml
from markdown import markdown
from mkdocs.config import load_config
-def test_html_minification_preserves_attribute_quotes_and_removes_comments():
- repo_root = Path(__file__).resolve().parents[1]
- config = yaml.safe_load((repo_root / "mkdocs.yml").read_text(encoding="utf-8"))
- minify = next(plugin["minify"] for plugin in config["plugins"] if "minify" in plugin)
- source = (
- ''
- )
-
- output = htmlmin.minify(source, **minify["htmlmin_opts"])
-
- assert 'class="md-footer"' in output
- assert 'data-empty=""' in output
- assert "internal" not in output
- assert len(output) < len(source)
-
-
def test_mkdocs_reads_sources_directly_through_support_plugin():
repo_root = Path(__file__).resolve().parents[1]
config = yaml.safe_load((repo_root / "mkdocs.yml").read_text(encoding="utf-8"))
diff --git a/tests/test_cache.py b/tests/test_cache.py
new file mode 100644
index 0000000..ff550a1
--- /dev/null
+++ b/tests/test_cache.py
@@ -0,0 +1,30 @@
+"""Shared build cache tests."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from nmteam_support.cache import content_digest, staged_path
+
+
+def test_content_digest_is_stable_for_the_same_inputs():
+ assert content_digest(b"encoder", b"payload") == content_digest(b"encoder", b"payload")
+
+
+def test_content_digest_changes_with_every_part_it_is_given():
+ baseline = content_digest(b"encoder", b"payload")
+
+ assert content_digest(b"other-encoder", b"payload") != baseline
+ assert content_digest(b"encoder", b"other-payload") != baseline
+ assert content_digest(b"encoderpayload") != baseline
+
+
+def test_staged_path_is_a_unique_sibling_of_the_entry(tmp_path: Path):
+ entry = tmp_path / "abc123"
+
+ staged = staged_path(entry)
+
+ assert staged.parent == entry.parent
+ assert staged != entry
+ assert staged.name.startswith("abc123.")
+ assert staged.name.endswith(".part")
diff --git a/tests/test_image_pipeline.py b/tests/test_image_pipeline.py
index b16242c..db44c12 100644
--- a/tests/test_image_pipeline.py
+++ b/tests/test_image_pipeline.py
@@ -1,8 +1,13 @@
"""Raster asset optimization tests."""
+from __future__ import annotations
+
+import os
+import shutil
from io import BytesIO
from pathlib import Path
+import pytest
from PIL import Image
import nmteam_support.image_pipeline as image_pipeline
@@ -17,33 +22,42 @@ def _gradient(mode: str = "RGB") -> Image.Image:
return image
-def test_optimize_assets_emits_quality_80_webp_and_jpeg_fallback(tmp_path: Path):
- assert hasattr(image_pipeline, "optimize_assets")
+@pytest.fixture
+def cache_dir(tmp_path: Path) -> Path:
+ return tmp_path / "cache" / "images"
+
+
+def test_optimize_assets_emits_webp_and_jpeg_fallback(tmp_path: Path, cache_dir: Path):
source = tmp_path / "source"
target = tmp_path / "target"
source.mkdir()
- image = _gradient()
- image.save(source / "photo.jpg", quality=95)
+ _gradient().save(source / "photo.jpg", quality=95)
- assert image_pipeline.optimize_assets(source, target) == 1
+ assert image_pipeline.optimize_assets(source, target, cache_dir=cache_dir) == 1
with Image.open(source / "photo.jpg") as decoded:
expected_jpeg = BytesIO()
decoded.save(expected_jpeg, "JPEG", quality=80, optimize=True, progressive=True)
expected_webp = BytesIO()
- decoded.save(expected_webp, "WEBP", quality=80, method=6)
+ decoded.save(
+ expected_webp,
+ "WEBP",
+ quality=image_pipeline.RASTER_QUALITY,
+ method=image_pipeline.WEBP_METHOD,
+ )
assert (target / "photo.jpg").read_bytes() == expected_jpeg.getvalue()
assert (target / "photo.webp").read_bytes() == expected_webp.getvalue()
-def test_optimize_assets_quantizes_png_fallback_and_preserves_alpha_in_webp(tmp_path: Path):
- assert hasattr(image_pipeline, "optimize_assets")
+def test_optimize_assets_quantizes_png_fallback_and_preserves_alpha_in_webp(
+ tmp_path: Path, cache_dir: Path
+):
source = tmp_path / "source"
target = tmp_path / "target"
source.mkdir()
_gradient("RGBA").save(source / "diagram.png")
- assert image_pipeline.optimize_assets(source, target) == 1
+ assert image_pipeline.optimize_assets(source, target, cache_dir=cache_dir) == 1
with Image.open(target / "diagram.png") as fallback:
assert fallback.mode == "P"
@@ -53,18 +67,80 @@ def test_optimize_assets_quantizes_png_fallback_and_preserves_alpha_in_webp(tmp_
assert "A" in preferred.getbands()
-def test_optimize_assets_skips_unchanged_rasters(tmp_path: Path):
- assert hasattr(image_pipeline, "optimize_assets")
+def test_optimize_assets_republishes_from_cache_after_the_site_is_wiped(
+ tmp_path: Path, cache_dir: Path
+):
+ """The cache outlives ``site/``, which MkDocs clears on every non-dirty build."""
+ source = tmp_path / "source"
+ target = tmp_path / "target"
+ source.mkdir()
+ Image.new("RGB", (16, 16), "red").save(source / "diagram.png")
+
+ assert image_pipeline.optimize_assets(source, target, cache_dir=cache_dir) == 1
+ webp = (target / "diagram.webp").read_bytes()
+
+ shutil.rmtree(target) # what MkDocs does to site/ before every non-dirty build
+
+ assert image_pipeline.optimize_assets(source, target, cache_dir=cache_dir) == 0
+ assert (target / "diagram.webp").read_bytes() == webp
+ assert (target / "diagram.png").is_file()
+
+
+def test_optimize_assets_reencodes_when_content_changes_under_the_old_stamp(
+ tmp_path: Path, cache_dir: Path
+):
+ """Entries are addressed by content, so a rewritten file cannot pass as unchanged."""
source = tmp_path / "source"
target = tmp_path / "target"
source.mkdir()
image = source / "diagram.png"
Image.new("RGB", (16, 16), "red").save(image)
+ stamp = image.stat().st_mtime_ns
+
+ assert image_pipeline.optimize_assets(source, target, cache_dir=cache_dir) == 1
+ original = (target / "diagram.webp").read_bytes()
+
+ Image.new("RGB", (16, 16), "blue").save(image)
+ os.utime(image, ns=(stamp, stamp))
- assert image_pipeline.optimize_assets(source, target, incremental=True) == 1
- fallback = target / "diagram.png"
- webp = target / "diagram.webp"
- mtimes = (fallback.stat().st_mtime_ns, webp.stat().st_mtime_ns)
+ assert image_pipeline.optimize_assets(source, target, cache_dir=cache_dir) == 1
+ assert (target / "diagram.webp").read_bytes() != original
+
+
+def test_optimize_assets_reencodes_when_encoder_parameters_change(
+ tmp_path: Path, cache_dir: Path, monkeypatch
+):
+ """Retuning the encoder must not keep publishing output from the previous settings."""
+ source = tmp_path / "source"
+ target = tmp_path / "target"
+ source.mkdir()
+ Image.new("RGB", (16, 16), "red").save(source / "diagram.png")
+
+ assert image_pipeline.optimize_assets(source, target, cache_dir=cache_dir) == 1
+
+ monkeypatch.setattr(image_pipeline, "_ENCODER", b"retuned")
+
+ assert image_pipeline.optimize_assets(source, target, cache_dir=cache_dir) == 1
+
+
+def test_optimize_assets_publishes_only_completed_cache_entries(tmp_path: Path, cache_dir: Path):
+ """Nothing partial may reach ``site/``: entries are staged before they are visible."""
+ source = tmp_path / "source"
+ target = tmp_path / "target"
+ source.mkdir()
+ Image.new("RGB", (16, 16), "red").save(source / "diagram.png")
+
+ image_pipeline.optimize_assets(source, target, cache_dir=cache_dir)
+
+ assert not list(cache_dir.glob("*.part"))
+ assert not list(target.glob("*.part"))
+
+
+def test_optimize_assets_ignores_non_raster_sources(tmp_path: Path, cache_dir: Path):
+ source = tmp_path / "source"
+ target = tmp_path / "target"
+ source.mkdir()
+ (source / "notes.txt").write_text("not an image", encoding="utf-8")
- assert image_pipeline.optimize_assets(source, target, incremental=True) == 0
- assert (fallback.stat().st_mtime_ns, webp.stat().st_mtime_ns) == mtimes
+ assert image_pipeline.optimize_assets(source, target, cache_dir=cache_dir) == 0
+ assert not target.exists()
diff --git a/tests/test_minify.py b/tests/test_minify.py
new file mode 100644
index 0000000..575b017
--- /dev/null
+++ b/tests/test_minify.py
@@ -0,0 +1,61 @@
+"""HTML minification tests."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+import nmteam_support.minify as minify
+from nmteam_support.minify import render_minified_html
+
+
+@pytest.fixture
+def cache_dir(tmp_path: Path) -> Path:
+ return tmp_path / "cache" / "html"
+
+
+def test_render_minified_html_removes_comments_and_keeps_attribute_quoting(cache_dir: Path):
+ source = (
+ ''
+ )
+
+ output = render_minified_html(source, cache_dir=cache_dir)
+
+ assert 'class="md-footer"' in output
+ assert 'data-empty=""' in output
+ assert "internal" not in output
+ assert len(output) < len(source)
+
+
+def test_render_minified_html_keeps_the_empty_tabindex_navigation_hook(cache_dir: Path):
+ """MkDocs Material binds its navigation state updates to ``label[tabindex]``.
+
+ The theme renders ``tabindex=""`` for collapsible sections, so a minifier that
+ drops empty attributes would silently detach that behaviour.
+ """
+ source = ''
+
+ output = render_minified_html(source, cache_dir=cache_dir)
+
+ assert 'tabindex=""' in output
+
+
+def test_render_minified_html_reuses_the_cached_result(cache_dir: Path, monkeypatch):
+ source = " spaced
"
+ first = render_minified_html(source, cache_dir=cache_dir)
+
+ def explode(_: str) -> str:
+ raise AssertionError("re-minified an input that was already cached")
+
+ monkeypatch.setattr(minify, "_minify", explode)
+
+ assert render_minified_html(source, cache_dir=cache_dir) == first
+
+
+def test_render_minified_html_caches_distinct_inputs_separately(cache_dir: Path):
+ assert render_minified_html("a
", cache_dir=cache_dir) != render_minified_html(
+ "b
", cache_dir=cache_dir
+ )
+
+ assert len(list(cache_dir.iterdir())) == 2
diff --git a/tests/test_plugin.py b/tests/test_plugin.py
index f2e8e48..e361a9f 100644
--- a/tests/test_plugin.py
+++ b/tests/test_plugin.py
@@ -112,3 +112,32 @@ def test_plugin_writes_final_images_and_markdown_copies_without_staging(tmp_path
assert "帮助我们改进此文档" in markdown_copy.read_text(encoding="utf-8")
assert not (tmp_path / "cache").exists()
assert not (tmp_path / "generated").exists()
+
+
+def test_cache_lives_outside_the_site_directory(tmp_path, docs_dir):
+ """MkDocs wipes ``site/`` every build, so a cache kept there could never be reused."""
+ image_path = tmp_path / "assets" / "images" / "diagram.png"
+ image_path.parent.mkdir(parents=True)
+ Image.new("RGB", (16, 16), "red").save(image_path)
+ plugin, config, _files = _context(tmp_path, docs_dir)
+ Path(config.site_dir).mkdir()
+
+ plugin.on_post_build(config=config)
+
+ assert (tmp_path / ".cache" / "images").is_dir()
+ assert not (Path(config.site_dir) / ".cache").exists()
+
+
+def test_pages_and_html_templates_are_minified_with_the_same_options(tmp_path, docs_dir):
+ plugin, config, files = _context(tmp_path, docs_dir)
+ markup = 'body
'
+ page = Page("MCP", files.get_file_from_path("nmbot-telegram/mcp.md"), config)
+
+ rendered = plugin.on_post_page(markup, page=page, config=config)
+ template = plugin.on_post_template(markup, template_name="404.html", config=config)
+ ignored = plugin.on_post_template("", template_name="sitemap.xml", config=config)
+
+ assert rendered == template
+ assert "comment" not in rendered
+ assert 'data-empty=""' in rendered
+ assert ignored == ""
diff --git a/uv.lock b/uv.lock
index 5e338d9..98225a9 100644
--- a/uv.lock
+++ b/uv.lock
@@ -98,12 +98,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
-[[package]]
-name = "csscompressor"
-version = "0.9.5"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f1/2a/8c3ac3d8bc94e6de8d7ae270bb5bc437b210bb9d6d9e46630c98f4abd20c/csscompressor-0.9.5.tar.gz", hash = "sha256:afa22badbcf3120a4f392e4d22f9fff485c044a1feda4a950ecc5eba9dd31a05", size = 237808, upload-time = "2017-11-26T21:13:08.238Z" }
-
[[package]]
name = "ghp-import"
version = "2.1.0"
@@ -154,12 +148,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
-[[package]]
-name = "jsmin"
-version = "3.0.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5e/73/e01e4c5e11ad0494f4407a3f623ad4d87714909f50b17a06ed121034ff6e/jsmin-3.0.1.tar.gz", hash = "sha256:c0959a121ef94542e807a674142606f7e90214a2b3d1eb17300244bbb5cc2bfc", size = 13925, upload-time = "2022-01-16T20:35:59.13Z" }
-
[[package]]
name = "markdown"
version = "3.10.3"
@@ -380,21 +368,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" },
]
-[[package]]
-name = "mkdocs-minify-plugin"
-version = "0.8.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "csscompressor" },
- { name = "htmlmin2" },
- { name = "jsmin" },
- { name = "mkdocs" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/52/67/fe4b77e7a8ae7628392e28b14122588beaf6078b53eb91c7ed000fd158ac/mkdocs-minify-plugin-0.8.0.tar.gz", hash = "sha256:bc11b78b8120d79e817308e2b11539d790d21445eb63df831e393f76e52e753d", size = 8366, upload-time = "2024-01-29T16:11:32.982Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1b/cd/2e8d0d92421916e2ea4ff97f10a544a9bd5588eb747556701c983581df13/mkdocs_minify_plugin-0.8.0-py3-none-any.whl", hash = "sha256:5fba1a3f7bd9a2142c9954a6559a57e946587b21f133165ece30ea145c66aee6", size = 6723, upload-time = "2024-01-29T16:11:31.851Z" },
-]
-
[[package]]
name = "more-itertools"
version = "11.1.0"
@@ -409,10 +382,10 @@ name = "nmteam-support"
version = "0.1.0"
source = { editable = "." }
dependencies = [
+ { name = "htmlmin2" },
{ name = "markdown" },
{ name = "mkdocs" },
{ name = "mkdocs-material" },
- { name = "mkdocs-minify-plugin" },
{ name = "pillow" },
{ name = "pyyaml" },
{ name = "typer" },
@@ -425,15 +398,16 @@ dev = [
{ name = "mdformat-front-matters" },
{ name = "mdformat-mkdocs" },
{ name = "pytest" },
+ { name = "pytest-benchmark" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
+ { name = "htmlmin2", specifier = ">=0.1.13" },
{ name = "markdown", specifier = ">=3.10" },
{ name = "mkdocs", specifier = "==1.6.1" },
{ name = "mkdocs-material", specifier = "==9.7.7" },
- { name = "mkdocs-minify-plugin", specifier = ">=0.8.0" },
{ name = "pillow", specifier = ">=12.1.0" },
{ name = "pyyaml", specifier = ">=6.0.2" },
{ name = "typer", specifier = ">=0.21.1" },
@@ -446,6 +420,7 @@ dev = [
{ name = "mdformat-front-matters", specifier = ">=2.0" },
{ name = "mdformat-mkdocs", specifier = ">=5.2.2" },
{ name = "pytest", specifier = ">=9.0" },
+ { name = "pytest-benchmark", specifier = ">=5.3.0" },
{ name = "ruff", specifier = ">=0.16" },
]
@@ -544,6 +519,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
+[[package]]
+name = "py-cpuinfo2"
+version = "10.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/dc/97/a8b1ddada14c8280a047c0746f95cb05d94a31b1a331cea22bcdc2b2a82d/py_cpuinfo2-10.1.1.tar.gz", hash = "sha256:7861133863663f16e06eca63b12904ef100b5760415e92372dac0162799a4771", size = 100840, upload-time = "2026-03-25T21:49:40.797Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/23/0a/ba69d2dde1ae12ef1d389ea5a216384c5ff6ef7a1e7a48d1e9b6686f6790/py_cpuinfo2-10.1.1-py3-none-any.whl", hash = "sha256:adc53396bfb206e6498d078ec2ab407f85799ecd819584ac36a8f80a2d4d762d", size = 23791, upload-time = "2026-03-25T21:49:39.574Z" },
+]
+
[[package]]
name = "pygments"
version = "2.20.0"
@@ -582,6 +566,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
+[[package]]
+name = "pytest-benchmark"
+version = "5.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "py-cpuinfo2" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/63/8f/83a15e40dbc34a580ee56eb56983cae5394c6e94d50cf28fe268e457be25/pytest_benchmark-5.3.0.tar.gz", hash = "sha256:358444d4e89be901ee2b6404fb043ac3d7684002ad7f3563cc153fca6339c965", size = 375410, upload-time = "2026-08-23T17:45:08.891Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/eb/42/7e80f7cfa191e0a766d1de99b4661847415ad5db34f8209d81fd42175b59/pytest_benchmark-5.3.0-py3-none-any.whl", hash = "sha256:920ab1dfcffa718d49aa15ba144c7e357bda59216a0dc308016cc1c7236f719d", size = 48401, upload-time = "2026-08-23T17:45:07.094Z" },
+]
+
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"