| name | mkdocs-material-deployment |
| description | Deploy MkDocs Material sites to Docker, Cloudflare Pages, and GitHub Pages. Covers CSS loading bugs, directory flattening, nginx config, theme overrides, search index limits, multi-environment consistency, plus the wiki→book compilation pipeline (book_compiler, chapter splitting, wikilink preprocessing), E2E testing, and operational fixes. Use when building, deploying, or debugging MkDocs Material sites. |
MkDocs Material Deployment
Deploy MkDocs Material sites to multiple environments (Docker + Cloudflare Pages + GitHub Pages) without breaking each other.
When to Load This Skill
- Building/deploying MkDocs Material sites
- Fixing CSS/layout issues in MkDocs sites
- Deploying to Docker + Cloudflare Pages + GitHub Pages
- Debugging broken links or styles in MkDocs output
CSS Loading — <base href="/"> Depends on Deployment Context
The rule is context-dependent, NOT a blanket ban.
When <base href="/"> IS needed (Docker/nginx clean URLs)
When nginx serves clean URLs (e.g., /ch01-ai-basics/ maps to ch01-ai-basics.html), the browser thinks it's in a subdirectory. Relative CSS paths like href=assets/stylesheets/main.xxx.css resolve to /ch01-ai-basics/assets/... → 404.
Fix: Add <base href="/"> to overrides/main.html:
{% extends "base.html" %}
{% block site_meta %}
{{ super() }}
<base href="/">
{% endblock %}
When <base href="/"> BREAKS things (GitHub Pages subpath)
GitHub Pages serves at /<repo-name>/ (e.g., /wiki-book/). A <base href="/"> causes all relative links to resolve to root (/) instead of /wiki-book/.
Fix: Do NOT use <base> tag. Instead ensure use_directory_urls: false in mkdocs.yml so links are explicit .html paths.
Decision matrix
| Environment | Clean URLs? | <base href="/">? | Why |
|---|
Docker/nginx (root /) | Yes | YES | Flat files served at subdirectory paths break CSS |
CF Pages (root /) | No (static) | Optional | Files at actual paths, relative works |
| GitHub Pages (custom domain) | No | NO | Custom domain serves at root, no subpath issue |
GitHub Pages (/repo/ subpath) | No | NO | Breaks subpath resolution |
Pitfalls:
- Block name is
site_meta, NOT head
- Dockerfile must
COPY overrides/ overrides/ before RUN mkdocs build
- If you have BOTH Docker and GitHub Pages deploys, use conditional Jinja or maintain separate overrides
GitHub Pages Subpath CSS Breakage (Critical)
When deploying to GitHub Pages WITHOUT a custom domain, the site lives at https://<user>.github.io/<repo>/. MkDocs with site_url: https://custom.domain generates root-relative CSS/JS paths (/assets/stylesheets/main.xxx.css) that resolve to https://<user>.github.io/assets/... instead of https://<user>.github.io/<repo>/assets/... → all styles 404.
Root cause: site_url in mkdocs.yml must match the ACTUAL deployment URL. If using a custom domain, that domain must have DNS configured — otherwise GitHub Pages falls back to the subpath silently.
Fix (two options):
Option A: Custom domain (preferred)
- Create
docs/CNAME with the custom domain (e.g., wiki.jinguo.tech)
- Set GitHub Pages custom domain:
gh api repos/<user>/<repo>/pages --method PUT --input - <<'EOF'
{"cname": "wiki.jinguo.tech", "source": {"branch": "main", "path": "/"}}
EOF
- Add DNS record:
CNAME wiki → <user>.github.io (gray cloud, DNS-only)
- Keep
site_url: https://wiki.jinguo.tech in mkdocs.yml
Option B: Subpath-relative paths (no custom domain)
Change site_url to match the subpath:
site_url: https://<user>.github.io/<repo>/
Then rebuild — MkDocs generates paths with <repo>/ prefix.
Pitfall: If you set a custom domain in GitHub Pages but the DNS record doesn't exist, the site falls back to subpath silently. Always verify DNS propagation before assuming custom domain works.
Duplicate Files: Flat vs Nested
MkDocs with use_directory_urls: true (default) generates BOTH:
- Flat:
ch01-001-xxx.html (at site root)
- Nested:
ch01/001-xxx.html (in subdirectory)
Which to keep depends on which format the HTML links use.
Check the actual link format in chapter index pages:
grep -o 'href=[^ >]*\.html' site/ch01-ai-basics.html | head -5
If links use subdirectory format (ch01/001-xxx.html) — KEEP subdirs, DELETE flats:
RUN cd site && find . -maxdepth 1 -name 'ch[0-9][0-9]-[0-9][0-9][0-9]-*.html' -delete
If links use flat format (ch01-001-xxx.html) — DELETE subdirs, KEEP flats:
RUN cd site && for d in ch01 ch02 ch03 ...; do rm -rf "$d" 2>/dev/null; done
CRITICAL: Never delete the format that links point to. Verify BEFORE writing the Dockerfile step.
Pitfall (discovered 2026-06-30): The old advice was "always delete subdirs." This broke wiki-book because chapter index pages used ch01/001-xxx.html links. After deleting subdirs, every article link 404'd.
Cloudflare Pages .html Redirect Pitfall (Critical)
When deployed via Cloudflare Pages, .html extension URLs trigger a 308 permanent redirect to the clean URL (e.g., /ch01/001-2026-15.html → /ch01/001-2026-15). This is built-in Cloudflare Pages behavior and cannot be disabled.
The problem: In some browsers or cached states, this 308 redirect can misresolve to a broken URL, replacing / with - (e.g., /ch01/001-2026-15 becomes /ch01-001-2026-15 → 404).
Fix: Remove .md extensions from source markdown links so MkDocs generates clean URLs directly (no .html → no redirect).
In split-chapters.py (or equivalent link-generation script):
# BEFORE — generates <a href="ch01/001-2026-15.html">
index_lines.append(f"- [{num}. {title}]({ch_prefix}/{num}-{slug}.md)\n")
# AFTER — generates <a href="ch01/001-2026-15">
index_lines.append(f"- [{num}. {title}]({ch_prefix}/{num}-{slug})\n")
nginx side: Must handle clean URLs via try_files:
location / {
try_files $uri $uri.html $uri/index.html =404;
}
Detection: After clicking an article link from the chapter index page, check window.location.href — if it shows ch01-001... (dash) instead of ch01/001... (slash), the 308 redirect misresolved.
Search Index Size Limits
search_index.json grows with content. Two CF Pages limits apply:
25MB per-file limit: A full rebuild can produce a 247MB search index, exceeding CF Pages' 25MB per-file cap:
Error: Pages only supports files up to 25 MiB in size
Fix: Remove the search index from deploy dir (it's uploaded to R2 separately):
rm -f site/search/search_index.json
wrangler pages deploy site ...
20,000 file limit (free plan): Switching use_directory_urls settings creates double files (flat + nested), hitting the cap.
- Syptom:
Pages only supports up to 20,000 files in a deployment for your current plan
- Fix: Always
rm -rf site/* && mkdocs build after changing use_directory_urls
- Never rely on
--dirty across a config change — it leaves stale files from both modes.
Chapter Index Link Format
Links in chapter index files must match the ACTUAL filename (which may contain Chinese characters). Use relative .html format:
❌ ](/ch01-001-2026-15/) # truncated English slug, file doesn't exist
❌ ](ch01/001-xxx.md) # nested path, wrong
✅ ](ch01-001-2026年...深度解读.html) # matches actual filename
Root cause: book compilation generates filenames from Chinese titles, but link-fix scripts may use English slugs from the wiki repo. Always verify chNN-NNN prefix maps to the actual file.
Batch fix script (map prefix → actual filename):
import os, re
file_map = {}
for f in os.listdir('docs'):
m = re.match(r'(ch\d+-\d+)', f.replace('.md',''))
if m: file_map[m.group(1)] = f.replace('.md','')
# Then replace ](/chNN-NNN-slug/) → ](actual.html)
Search Index Optimization (Critical)
search_index.json is ~68MB for 2000+ articles. This causes Chrome tab crashes (Error code 5) when browsing. MUST slim after every mkdocs build.
Always run after build:
python3 scripts/slim-search-index.py
# 68MB → 8MB (71772 → 21185 entries)
The slim script:
- Keeps max 5 entries per page, 300 char text
- Excludes chapter index pages and references
- Result (~8MB) is under CF Pages 25MB limit
Build pipeline:
# scripts/build.sh
docker run --rm -v "$(pwd):/build" -w /build wiki-book-builder:latest mkdocs build
python3 scripts/slim-search-index.py
CF Pages deploy: run slim BEFORE deploy, not find -size +25M -delete (which destroys the index):
python3 scripts/slim-search-index.py # reduces to ~8MB
npx wrangler pages deploy site --project-name=...
nginx Configuration
Don't use rewrite ... permanent — it generates redirects with Docker's internal port:
# WRONG: changes port from 8002 to 8080
rewrite ^/(.*)/$ /$1 permanent;
# RIGHT: serve directly
location ~ ^(.+)/$ {
try_files $1 $1.html $1/index.html =404;
}
location / {
try_files $uri $uri.html $uri/index.html =404;
}
Multi-Environment Deploy
docs/ → Docker build (inside container) → localhost:8002
docs/ → local mkdocs build → site/
site/ → wrangler deploy → Cloudflare Pages
docs/ → git push → GitHub Actions → GitHub Pages
Critical: Docker build and local site/ are INDEPENDENT. Editing docs + docker compose up -d --build does NOT update site/. Must rebuild site/ separately for Cloudflare deployment.
Rebuild Commands
# Docker (picks up source changes)
docker compose down && docker compose create && docker compose start
# Local site/ (for Cloudflare)
docker run --rm -v "$(pwd):/build" -w /build <builder-image> mkdocs build
# Cloudflare
npx wrangler pages deploy site --project-name=<name>
# GitHub
git add -A && git commit && git push
Wiki-Book Pipeline, Compilation & Operations (absorbed skills)
Three absorbed skills keep their full content as references under this umbrella:
references/wiki-book-pipeline.md (absorbed wiki-book-deployment) — the end-to-end wiki→book→site pipeline for ~/wiki-book: book_compiler.py → mkdocs_prepare.py → split-chapters.py → fix-docs-links.py → sync-wiki-book.sh → full mkdocs build (never --dirty) → slim-search-index.py → 3-env deploy. Includes: root vs deploy/docker/ Dockerfile trap, .dockerignore site/ exclusion, CF Pages 20K-file / 25MB-per-file limits (delete search_index.json AND neighbor_graph.json before wrangler pages deploy — ordering matters), .html-not-.md link suffixes, articles.json staleness, dashboard-only deploy, article-numbering shifts on recompilation, and its dated session references (docker-build-timeout, extended-links regression, proxy clean-build, dashboard-404, tag-comparison workflow).
references/wiki-book-operations.md (absorbed wiki-book-operations) — operational playbook: 7-phase E2E testing (HTTP status → HTML structure → internal links → Playwright rendering → link jumps → RAG query test → report; test against localhost:8002, Cloudflare blocks headless Playwright), prev/next navigation JS (URL pattern matching, cache-busting), entity page URL structure, .md→.html wikilink conversion (must be in mkdocs_prepare.py, NOT fix-docs-links.py), htmlmin exclude_docs: raw/, PATH.md {{BASE_URL}} link wrapping, dashboard 404 diagnosis (articles.json / articleUrl() / D1 orphaned records), batch h1 downgrade, article-count data sources (three different counters — entities vs raw vs sub-pages), deploy chains for index.md and references.md.
references/knowledge-base-to-book.md (absorbed knowledge-base-to-book) — generic wiki→book compilation methodology: 5-level entity classification (tag scoring, not first-match), 20-chapter topic scoring, wikilink preprocessing for MkDocs/pandoc, Chinese-content MkDocs config (templates/mkdocs.yml), EPUB generation via pandoc, and pitfalls (YAML --- separators, blob: URLs, citation-in-URL corruption).
Also absorbed: scripts/fix-path-entity-links.py, scripts/fix-flat-filenames.py (from wiki-book-operations), references/preprocessing.py + templates/mkdocs.yml (from knowledge-base-to-book).
Reference Files
references/r2-search-index.md — R2 integration for large search indexes
references/byok-config-form.md — BYOK config form pattern (endpoint + API key + model inputs with presets)
references/video-export-via-playwright.md — Video export using app-as-renderer pattern
references/duplicate-files-in-compiled-outputs.md — Detecting flat/nested duplicates and slug variants
references/escaped-characters-in-articles.md — Fixing \\n \\t \\" in ingested content
references/css-customization-pitfalls.md — CSS specificity issues, !important requirements, key selectors for sidebar/footer, dark mode, gradient effects
references/wiki-book-content-remediation.md — Batch h1 downgrade, HTML residue cleanup, entity reference path bugs, Playwright E2E testing methodology for 4000+ article sites
references/playwright-ai-chat-e2e.md — Playwright E2E test script for AI Chat panel (trigger, settings, send message, TTS, clear, close)
User Preference: Compact Footer
User prefers tight, minimal-whitespace footer design. When styling .md-footer, start with compact values:
- Footer card padding:
0.25rem 0.65rem (NOT 0.75rem 1.1rem)
- Footer inner padding:
0.15rem 1.2rem
- Meta bar padding:
0.25rem 1.2rem
- Direction label font:
0.55rem
- Title font:
0.75rem
Anti-pattern: Do NOT start with generous padding (0.75rem+) — user will immediately ask to reduce. Start tight, expand only if asked.
Full compact footer CSS: references/css-customization-pitfalls.md → "Compact Footer Styling" section.
Pitfalls
site/ output: MkDocs compiles .md → .html
Post-build scripts that reference files in site/ must use .html extension, NOT .md. MkDocs always compiles Markdown to HTML. For example, site/PATH.md does not exist — the file is site/PATH.html.
# WRONG — file not found:
cp "$BOOK/site/PATH.md" /tmp/
sed -i 's|{{BASE_URL}}||g' "$BOOK/site/PATH.md"
# RIGHT:
cp "$BOOK/site/PATH.html" /tmp/
sed -i 's|{{BASE_URL}}||g' "$BOOK/site/PATH.html"
Detection: Script exits with cp: ... No such file or directory at a step operating on a .md path in site/.
CI OOM: Large-Scale Build Steps on GitHub Actions
Symptom: mkdocs build or post-build scripts (e.g., neighbor-graph generation) work fine locally (M1/M2/M3/M4/M5 with 16GB+ RAM) but fail silently or timeout on GitHub Actions runners (2-core, 7GB RAM).
Root cause: GitHub Actions free-tier runners have limited memory. Steps that work on local Apple Silicon machines can hit OOM on the shared runner, especially:
- Large-scale content processing (60K+ documents, sparse matrix operations like 63K×366K)
- Search index generation with 2000+ articles
- Post-build aggregation/analysis scripts
Fix options:
-
Remove the step from CI (if the step is informational, not build-critical):
# BEFORE:
- run: python3 scripts/build-neighbor-graph.py
# AFTER: removed entirely
-
Run locally: Move memory-intensive steps to a local cron or manual trigger
-
Upgrade runner: Use ubuntu-latest-m (16GB) or self-hosted runner for heavy steps
Detection:
- CI passes
mkdocs build but fails on post-build step
- Logs show Killed: 9 or process exit code 137
- The step runs fine on
--help or with minimal input
Pitfall: A removed step is gone from CI but may still be referenced in docs or local scripts. Remove from both CI YAML AND the build wrapper script (scripts/build.sh), otherwise the wrapper references a script that no longer runs and the user mistakenly thinks it's still active.
docker compose build --no-cache pulls base images from Docker Hub. If auth.docker.io is unreachable (common behind proxies or in China), the build hangs on load metadata for docker.io/library/nginx:alpine.
Fix: Pre-pull the base images separately first — they often succeed with retries:
docker pull nginx:alpine # pull separately with longer timeout
docker pull python:3.12-slim
docker compose build --no-cache # now uses local cache for base images
Multiple Dockerfiles
If both ./Dockerfile and ./deploy/docker/Dockerfile exist, docker compose uses ./Dockerfile (root). Changes to deploy/docker/Dockerfile won't take effect unless docker-compose.yml explicitly references it. Always check which file docker compose build actually uses — look at the #2 [internal] load build definition from Dockerfile line in build output.
Escaped Characters in Ingested Articles
Wiki entity ingestion sometimes preserves literal \\n, \\t, \\" instead of converting to actual characters. Result: entire article content appears as one unreadable line in H1 heading.
Detection:
grep -rl '\\\\n' docs/ch*.md | wc -l
Fix (batch):
content = content.replace('\\\\n', '\n')
content = content.replace('\\"', '"')
content = content.replace('\\t', '\t')
Slug-ified Headings
Some articles have H1/H2 headings that are the filename slug (e.g., # 准备开一个新坑从零复刻一个-claude-codenn目标...) instead of the actual title. The real title is usually the second H1.
Detection: H1 > 200 chars or contains -codenn/-ha.md patterns.
Fix: Remove slug H1/H2 + metadata block, keep the readable H1.
CSS Customization (Critical Pitfall)
MkDocs Material's built-in CSS silently overrides custom styles. Every property the theme also sets needs !important — including pseudo-element content, position, background.
Quick check: If your CSS isn't taking effect, run getComputedStyle(el).propName in the browser console. If the value differs, add !important.
Full reference: references/css-customization-pitfalls.md — key selectors for sidebar/footer, dark mode patterns, gradient effects, hover animations.
Embedding AI Chat in Sidebar (CORS Proxy Pattern)
When adding a browser-side LLM chat panel to MkDocs Material, direct API calls from the browser fail due to CORS. Solution: Cloudflare Worker as a transparent proxy.
Architecture:
Browser → CF Worker (CORS proxy) → LLM API (OpenRouter / 讯飞 / DeepSeek / etc.)
Why Worker, not direct fetch:
- Most LLM APIs (except OpenAI/OpenRouter) don't send CORS headers
- Worker hides API Key from frontend
- Single proxy works for all providers (OpenAI-compatible format)
Implementation (3 files):
overrides/assets/stylesheets/ai-chat.css — chat panel styling
overrides/assets/javascripts/ai-chat.js — chat logic + streaming SSE
overrides/main.html — inject CSS/JS via styles/scripts blocks
Key JS pattern — pass config via custom headers, not URL/body:
fetch('https://ai-chat-proxy.workers.dev', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Base': cfg.apiBase, // e.g. https://maas-api.xf-yun.com/v2
'X-API-Key': cfg.apiKey, // hidden from frontend
'X-Model': cfg.model // e.g. xopqwen36v35b
},
body: JSON.stringify({ messages, stream: true })
});
Worker implementation: references/ai-chat-cors-proxy.md — covers BOTH modes below.
Model Configuration
Two UI patterns depending on mode:
Server-side key mode — Model cards (visitors pick from preset list, no API key input):
- ⚙️ gear button in header toggles
.ai-chat__settings div
- Model cards are
<button class="ai-chat__model-card" data-model="..."> with .active highlight
localStorage.getItem("ai-chat-model") persists selection
- Pass
model: getSelectedModel() in request body
- Worker must respect
payload.model over env.MODEL
BYOK mode — Full config form (users enter their own endpoint + API key + model):
- ⚙️ gear button toggles settings panel with preset dropdown + input fields
- Preset dropdown auto-fills endpoint/model for known providers (DeepSeek, 讯飞, MiMo, OpenAI-compatible)
- Fields: Endpoint URL (text), API Key (password), Model ID (text)
- Save button writes to
localStorage.getItem("ai-chat-config") as JSON {endpoint, apiKey, model}
- On chat send: if config has endpoint+key → direct fetch to user's API; else → fall back to proxy
- Direct fetch:
Authorization: Bearer <key> header, model in body
- Dual-service: If widget has TTS, add TTS Endpoint/Key/Model fields below chat fields, separated by divider. Save button at VERY BOTTOM of entire form. Full pattern:
references/byok-config-form.md → "Dual-Service Config Form" section.
Pitfall: If the Worker ignores the model field in the request body, model switching has zero effect. Always verify Worker code reads payload.model.
Full implementation: references/ai-chat-cors-proxy.md → "BYOK Config Form" section.
Full implementation: references/ai-chat-cors-proxy.md → "Model Configuration UI" section.
Two Proxy Modes (Critical)
| Mode | API Key Location | User Config Needed? | Use Case |
|---|
| Frontend-provided | Browser localStorage | Yes (each user enters their own key) | Internal/dev tools, multi-user BYOK |
| Server-side | Worker env or hardcoded | No (zero-config) | Public sites, shared knowledge bases |
Server-side mode (preferred for public sites):
- Worker holds API key + base + model — frontend sends NO key headers
- Remove settings panel from JS entirely
- TTS proxy already follows this pattern (see
deploy/cloudflare/tts-proxy/worker.js)
- Use
wrangler secret put for production keys, or hardcode for dev
Frontend-provided mode (current default):
- Frontend sends `Authorization: Bearer *** header with user's API key
- localStorage config
{endpoint, apiKey, model} with settings gear button + preset dropdown + input fields
- Each visitor configures their own API key (or uses site proxy if unconfigured)
- Full BYOK form pattern:
references/byok-config-form.md
Switching from frontend → server-side:
- Add env vars to Worker (
wrangler secret put API_KEY)
- Worker reads from
env instead of request headers
- Remove
X-API-Key header from frontend fetch
- Remove settings panel + localStorage config from JS
- Remove
configured conditional — always show chat UI
Pitfalls:
- When asked "does the user need to fill in their own key?" — always READ the code first, don't assume based on the proxy name. The Worker may or may not hold the key server-side.
- 讯飞 MaaS uses
wss:// WebSocket — must use http:// endpoint instead
- 讯飞 API Key format:
appid:secret (colon-separated)
- Config not saving → check localStorage is available (not in incognito with restrictions)
- "Failed to fetch" after config → config wasn't saved; inject via console to test
- NEVER hardcode API keys in Worker source files that are tracked by git — use
wrangler secret put instead. If keys are already in git, immediately:
- Rewrite Worker to read from
env (Cloudflare env vars)
wrangler secret put API_KEY to set the secret server-side
git rm --cached deploy/cloudflare/*/worker.js to stop tracking
- Add
deploy/cloudflare/*/worker.js to .gitignore
wrangler deploy to redeploy with env-based secrets
git add .gitignore && git commit -m "remove worker secrets from git"
User Preference: Release Before Risky Changes
Rule: Before implementing a new feature (e.g., AI chat panel), always create a git tag + GitHub release first so the user can roll back if the implementation breaks something.
git tag -a v1.2.1 -m "stable snapshot before X feature"
git push origin v1.2.1
gh release create v1.2.1 --title "v1.2.1: stable snapshot" --notes "..."
User explicitly requested this: "先打一个 Tag,通过playwright测试通过后发布docker,github-pages和cloudflare".
User Preference: Don't Dismiss Architectural Complexity
When user asks "can A and B be combined?", don't dismiss option B as "过度设计" (over-engineering). Re-evaluate based on the actual use case:
- 4000+ article knowledge base → RAG adds real value (cross-article discovery)
- Single-document Q&A → RAG is overkill
- Always assess the scale before dismissing
User correction: "企业知识库 (需RAG) 为什么是过度设计" → RAG was actually appropriate for their 4000-article wiki-book.
MkDocs JS Initialization Timing
MkDocs Material loads JavaScript asynchronously. Custom JS that interacts with the DOM must wait for initialization:
// WRONG — DOM not ready:
init();
// RIGHT — wait for MkDocs to finish:
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
setTimeout(init, 100); // MkDocs needs ~100ms after DOMContentLoaded
}
Playwright pitfall: After browser_navigate, custom JS elements (like a chat trigger button) may not exist in the DOM yet. Always wait 2-3 seconds before querying:
// Wait for custom JS to initialize
new Promise(function(resolve) { setTimeout(resolve, 3000); })
.then(function() {
var trigger = document.querySelector('.ai-chat-trigger');
// now trigger exists
});
Floating Chat Panel Pattern (Not Sidebar)
User preference: Sidebar chat panels are "太窄了,阅读起来效果太差" (too narrow, poor reading experience). Use a fixed-position floating panel instead:
┌─────────────────────────────────────┐
│ [🤖] ← fixed bottom-right button │
│ │
│ ┌───────────────────────┐ │
│ │ Talk to AI ✕ │ ← panel │
│ │ │ 420px │
│ │ [chat messages] │ wide │
│ │ │ │
│ │ [input field] [→] │ │
│ └───────────────────────┘ │
└─────────────────────────────────────┘
CSS (default left side — user preference):
.ai-chat-trigger {
position: fixed; bottom: 1.5rem; left: 1.5rem;
width: 3rem; height: 3rem; border-radius: 50%;
z-index: 100;
}
.ai-chat-panel {
position: fixed; bottom: 5.5rem; left: 1.5rem;
width: 420px; max-width: calc(100vw - 2rem);
height: 560px; max-height: calc(100vh - 8rem);
z-index: 99; display: none;
}
.ai-chat-panel.open { display: flex; }
Anti-pattern: Don't put chat panels inside .md-sidebar--primary — the sidebar is ~200px wide, making text unreadable.
Resizable Panel
Add CSS resize: both to make the panel resizable from all edges and corners:
.ai-chat-panel {
resize: both;
min-width: 300px;
min-height: 200px;
}
Pitfall: Must set min-width and min-height or the panel can be dragged to zero size. The native resize handle appears in the bottom-right corner.
Draggable Panel
Use mousedown/mousemove/mouseup on the header element. Key: switch from bottom/right to left/top positioning on first drag:
header.addEventListener("mousedown", function(e) {
if (e.target.closest("[data-action]")) return; // don't intercept button clicks
isDragging = true;
var rect = panel.getBoundingClientRect();
startX = e.clientX; startY = e.clientY;
startLeft = rect.left; startTop = rect.top;
panel.style.left = startLeft + "px";
panel.style.top = startTop + "px";
panel.style.right = "auto"; // critical: detach from right/bottom
panel.style.bottom = "auto";
e.preventDefault();
});
document.addEventListener("mousemove", function(e) {
if (!isDragging) return;
var newLeft = Math.max(0, Math.min(startLeft + e.clientX - startX, window.innerWidth - panel.offsetWidth));
var newTop = Math.max(0, Math.min(startTop + e.clientY - startY, window.innerHeight - 60));
panel.style.left = newLeft + "px";
panel.style.top = newTop + "px";
});
document.addEventListener("mouseup", function() { isDragging = false; });
Pitfall: If you don't switch to left/top positioning, dragging has no effect because bottom/right anchors fight the mouse position.
Pitfall (2026-06-30): If the header contains interactive elements besides [data-action] buttons, the drag handler's mousedown will intercept clicks on them, making them non-functional. When using a settings panel (⚙️ gear button), the panel itself is outside the header so no exclusion needed. But if you put a <select> or other interactive element in the header, exclude it:
header.addEventListener("mousedown", function(e) {
if (e.target.closest("[data-action]")) return;
if (e.target.closest(".ai-chat__model-select")) return; // ← only if select is in header
isDragging = true;
// ...
});
Recommended: Use a ⚙️ gear button (with data-action="settings") that toggles a settings panel BELOW the header. Model cards go in the panel, not the header. This avoids the drag interception issue entirely.
User preference (2026-06-30): User wants proper configuration forms, not minimal UIs. When asked to add model selection, a small <select> dropdown was rejected ("为什么没有配置的节目" — "why is there no configuration interface"). Use full form inputs (Endpoint, API Key, Model) with a preset dropdown for quick setup. The BYOK pattern (references/byok-config-form.md) is the expected baseline for any configurable chat widget.
Default Position: Left Side
User preference: chat panel defaults to left side, not right. Change CSS:
.ai-chat-trigger { left: 1.5rem; } /* NOT right */
.ai-chat-panel { left: 1.5rem; } /* NOT right */
E2E Testing (Multi-Phase for Large Sites)
For sites with 1000+ pages, use the layered approach in references/e2e-testing-large-static-sites.md:
- Phase 1: Bulk HTTP status check (curl -sI on every page)
- Phase 2: HTML structure analysis (h1, article tag, content length, entity links)
- Phase 3: Playwright sampling per chapter (CSS loaded, sidebar, TOC, footer)
- Phases 4-6: Link traversal, custom JS features, report generation
Quick Playwright Checks (Post-Deploy)
After any deployment, verify with actual browser clicks, not curl/grep:
- Navigate to homepage → verify content renders
- Click key nav tabs → verify no 404s
- Click article links → verify URL stays on correct domain/port
- Check
window.location.href for domain/port changes after each click
AI Chat Panel Playwright Testing
When the site includes a custom AI Chat panel, test with the script pattern from references/playwright-ai-chat-e2e.md:
- Trigger button visibility, panel open/close
- Settings panel (presets, endpoint, API key, model, TTS fields)
- Config save → verify "已保存 ✓" status
- Send message → wait for streaming response (non-empty bubble, no typing indicator)
- Drag handle, clear chat, close panel
Pitfall: Playwright's localStorage is cleared between navigations. Verify config save via status message, not reload.
Multi-Environment Isolation
Changes to docs/ source affect ALL environments:
- Docker: needs
mkdocs build inside container + restart
- CF Pages: needs local
mkdocs build → site/ → wrangler deploy
- GH Pages:
git push triggers GitHub Actions build
After editing docs/, ALL THREE must be rebuilt. Never assume one deploy covers all.
Docker vs Local site/ Divergence
After docker compose up -d --build, Docker's content may differ from local site/:
docker exec <container> ls /usr/share/nginx/html/ | head -5
Verify from the container, not from local site/ directory.
OG Meta & Favicon Configuration
theme:
icon:
logo: material/book-open-page-variant
favicon: assets/images/favicon.png # Place in docs/assets/images/
extra:
social:
- icon: fontawesome/brands/github
link: https://github.com/your/repo
generator: false # Hide "Made with MkDocs"
For custom OG tags, add via extra.meta in mkdocs.yml or inject in main.html override.
References Index Generation
Generate a references page from wiki raw articles:
# scripts/generate-references.py
# Extracts title + source_url from raw/articles/*.md frontmatter
# Groups by domain, generates docs/references.md
# Filters invalid URLs (N/A, unknown, local paths)
Run: python3 scripts/generate-references.py → adds to nav as 参考文献: references.md
See references/batch-markdown-fixes.md for common content remediation:
- Duplicate h1 → h2 downgrade
- HTML residuals from scraped content
- Dual
styles.css sync pitfall
MkDocs Component Reference Files
| Reference | Description |
|---|
references/mkdocs-link-fixing-patterns.md | Fixing GitHub raw URLs, relative links, double-slash, plain-text items in link sections |
references/e2e-testing-large-static-sites.md | Full 3-phase E2E methodology for 1000+ page sites |
references/batch-markdown-fixes.md | h1→h2 downgrade, HTML residual cleanup, dual styles.css sync |
references/r2-search-index.md | R2 integration for large search indexes |
references/css-customization-pitfalls.md | CSS specificity, !important requirements, sidebar/footer/dark mode |
references/video-export-via-playwright.md | Video export using app-as-renderer pattern |
references/duplicate-files-in-compiled-outputs.md | Flat/nested duplicates and slug variants |
references/escaped-characters-in-articles.md | Fixing \\n \\t \\" in ingested content |
references/wiki-book-content-remediation.md | Batch content fixes, E2E testing methodology |
references/playwright-ai-chat-e2e.md | Playwright E2E test script for AI Chat panel |
references/ai-chat-cors-proxy.md | CORS proxy Worker for LLM API integration |
references/byok-config-form.md | BYOK config form pattern (endpoint + API key + model) |
references/prev-next-entity-navigation.md | Custom JS prev/next nav for entity pages; URL regex patterns for both flat and subdirectory formats |