* feat(scripts): add static website generator from markdown sources (#85) Generate an elegant, mobile-friendly static site from the existing tutorial markdown files. The markdown remains the single source of truth — `scripts/build_website.py` reads from the same `.md` files the EPUB builder uses, rewrites cross-references to site URLs, and rewrites references to non-markdown repo files (`.json`, `.sh`, `.py`) to GitHub blob URLs so users can jump to the source on github.com. Highlights: - Reuses the chapter ordering convention from `build_epub.py` - Anchor algorithm mirrors `check_cross_references.heading_to_anchor` for parity with the validator - Mermaid renders client-side via `mermaid.js` (no pre-render step) - Tailwind CSS via CDN; light/dark theme toggle; sidebar nav; in-page TOC; prev/next page navigation; mobile responsive - 27 unit + smoke tests covering anchors, link rewriting (including `<source srcset>` inside `<picture>`), Mermaid handling, and a full end-to-end build - GitHub Pages deploy workflow at `.github/workflows/pages.yml` Closes #85 * fix(website): use relative URLs in sidebar nav and avoid INDEX.html collision Two bugs found by local browser dogfooding: 1. **Sidebar nav broke from deep pages.** `build_navigation` emitted raw `output_url` values (site-root-relative) which made every sidebar link 404 from any page below the root. Moved the call inside the per-page render loop so each page gets nav links computed relative to its own URL — `01-slash-commands/index.html` from the root, `../01-slash-commands/...` from a depth-1 page, `../../01-slash-commands/...` from depth-2. 2. **`INDEX.md` overwrote `index.html`.** On case-insensitive filesystems (macOS/Windows), `INDEX.html` and `index.html` are the same file, so `INDEX.md` clobbered the rendered `README.md`. Added `_disambiguate_url` that detects case-insensitive collisions and suffixes the colliding page with its source stem (`INDEX-index.html`). Added 2 tests; full suite stays at 83 passed. * fix(scripts): skip URLs with port in localhost/127.0.0.1 skip list `check_links.is_skipped()` did an exact-match comparison against the host, so `http://localhost:8080` (used in scripts/README.md as a preview example) was not skipped and CI's link check tried to fetch it, which fails on the GitHub runner. Strip the port before comparing. * chore(scripts): drop vestigial mypy ignore_errors for build_website The override silenced all mypy errors for build_website, making the "mypy: clean" claim technically vacuous. Removing it shows mypy is actually clean — 0 issues on build_website after type annotations were added during PR review. * feat(website): self-host Tailwind, Mermaid, and Inter fonts Drop all third-party CDN dependencies from rendered pages. The site previously loaded Tailwind from cdn.tailwindcss.com (Play CDN — JIT compile in browser, marked not-for-production), Mermaid from cdn.jsdelivr.net, and Inter/JetBrains Mono from fonts.googleapis.com. Replace with a vendored toolchain: - scripts/vendor_assets.py downloads the Tailwind standalone CLI (Go binary, no Node toolchain), Mermaid's UMD bundle, and Google Fonts CSS + WOFF2 files. Cached under scripts/.vendor-cache/ (gitignored), refetched only when missing. - Tailwind compiles a per-build site/assets/tailwind.css with only the utility classes actually used by the rendered HTML. - Mermaid and font files land in site/assets/vendor/ and load via relative URLs. - Tailwind config + entry CSS live in scripts/website_templates/ alongside the Jinja template. - build_website grows a skip_vendor flag so the smoke test runs offline. - pre-commit mypy hook gets types-Markdown so it can resolve the same imports as the project venv. Verification: 86/86 pytest pass, ruff/mypy/bandit clean, full build produces a working site with zero external requests (verified in a headless browser — no console errors, no failed network calls, Mermaid diagrams render). * fix(website): use tree URLs for repo directory links (#85) * fix(website): include additional top-level docs (#85)
This commit is contained in:
52
.github/workflows/pages.yml
vendored
Normal file
52
.github/workflows/pages.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
name: Deploy Website to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- '**/*.md'
|
||||||
|
- 'scripts/build_website.py'
|
||||||
|
- 'scripts/vendor_assets.py'
|
||||||
|
- 'scripts/website_templates/**'
|
||||||
|
- '.github/workflows/pages.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pages
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build static site
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v4
|
||||||
|
|
||||||
|
- name: Build website
|
||||||
|
run: uv run scripts/build_website.py --verbose
|
||||||
|
|
||||||
|
- name: Upload Pages artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: site
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy to GitHub Pages
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -90,3 +90,8 @@ promo-video/
|
|||||||
*.mp4
|
*.mp4
|
||||||
|
|
||||||
update-plan*.md
|
update-plan*.md
|
||||||
|
site/
|
||||||
|
.gstack/
|
||||||
|
|
||||||
|
# Cached vendor binaries/assets for the website build (Tailwind CLI, Mermaid, fonts).
|
||||||
|
scripts/.vendor-cache/
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ repos:
|
|||||||
args: [--config-file, scripts/pyproject.toml]
|
args: [--config-file, scripts/pyproject.toml]
|
||||||
files: ^scripts/
|
files: ^scripts/
|
||||||
exclude: ^scripts/tests/
|
exclude: ^scripts/tests/
|
||||||
|
additional_dependencies:
|
||||||
|
- types-Markdown
|
||||||
|
|
||||||
# Local doc quality hooks (mirrors CI checks — CI is a 2nd pass of these)
|
# Local doc quality hooks (mirrors CI checks — CI is a 2nd pass of these)
|
||||||
- repo: local
|
- repo: local
|
||||||
|
|||||||
@@ -3,6 +3,19 @@
|
|||||||
<img alt="Claude How To" src="../resources/logos/claude-howto-logo.svg">
|
<img alt="Claude How To" src="../resources/logos/claude-howto-logo.svg">
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
|
# Build Scripts
|
||||||
|
|
||||||
|
This directory contains two generators that turn the tutorial markdown files
|
||||||
|
into distributable formats:
|
||||||
|
|
||||||
|
- [**EPUB Builder**](#epub-builder-script) — `build_epub.py`
|
||||||
|
- [**Static Website Builder**](#static-website-builder) — `build_website.py`
|
||||||
|
|
||||||
|
Both treat the `.md` files as the single source of truth — re-run the relevant
|
||||||
|
script after editing markdown to regenerate the output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# EPUB Builder Script
|
# EPUB Builder Script
|
||||||
|
|
||||||
Build an EPUB ebook from the Claude How-To markdown files.
|
Build an EPUB ebook from the Claude How-To markdown files.
|
||||||
@@ -118,3 +131,80 @@ Managed via PEP 723 inline script metadata:
|
|||||||
**Rate limiting**: Reduce concurrent requests with `--max-concurrent 3`.
|
**Rate limiting**: Reduce concurrent requests with `--max-concurrent 3`.
|
||||||
|
|
||||||
**Missing logo**: The script generates a text-only cover if `claude-howto-logo.png` is not found.
|
**Missing logo**: The script generates a text-only cover if `claude-howto-logo.png` is not found.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Static Website Builder
|
||||||
|
|
||||||
|
Generate an elegant, mobile-friendly static website from the same markdown
|
||||||
|
files used by the EPUB build. The website is the rendered view; the `.md`
|
||||||
|
files remain the single source of truth.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- One HTML page per markdown source — internal `.md` links are rewritten to
|
||||||
|
the corresponding pages on the site
|
||||||
|
- References to non-markdown repo files (templates, scripts, JSON) become
|
||||||
|
GitHub blob URLs that open the source on github.com
|
||||||
|
- Mermaid diagrams render client-side via `mermaid.min.js`, served from the
|
||||||
|
built site (no CDN at runtime)
|
||||||
|
- Tailwind CSS compiled with the standalone CLI (Go binary, no Node.js) and
|
||||||
|
served from the built site — responsive layout with sidebar nav, in-page
|
||||||
|
TOC, dark mode toggle, and prev/next page navigation
|
||||||
|
- Inter + JetBrains Mono fonts are self-hosted alongside the CSS — no
|
||||||
|
third-party requests at page load
|
||||||
|
- Mirrors the EPUB curriculum order (`01-` … `10-` plus top-level docs)
|
||||||
|
- Hostable as plain static files — designed to deploy to GitHub Pages
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the English website into ./site/
|
||||||
|
uv run scripts/build_website.py
|
||||||
|
|
||||||
|
# Preview locally
|
||||||
|
python -m http.server --directory site 8080
|
||||||
|
# then open http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
## Command-Line Options
|
||||||
|
|
||||||
|
```
|
||||||
|
usage: build_website.py [-h] [--root ROOT] [--output OUTPUT]
|
||||||
|
[--lang {en,vi,zh,ja,uk}] [--repo-url REPO_URL]
|
||||||
|
[--branch BRANCH] [--verbose]
|
||||||
|
|
||||||
|
options:
|
||||||
|
--root, -r ROOT Source root (default: repo root)
|
||||||
|
--output, -o OUTPUT Output directory (default: <repo>/site)
|
||||||
|
--lang LANG Language to build: en | vi | zh | ja | uk
|
||||||
|
--repo-url URL GitHub repo for blob links (default: luongnv89/claude-howto)
|
||||||
|
--branch BRANCH Branch for blob links (default: main)
|
||||||
|
--verbose, -v Enable verbose logging
|
||||||
|
```
|
||||||
|
|
||||||
|
## GitHub Pages Deploy
|
||||||
|
|
||||||
|
The repo ships a workflow at `.github/workflows/pages.yml` that builds the
|
||||||
|
site on every push to `main` (when any `.md` or generator file changes) and
|
||||||
|
publishes via `actions/deploy-pages`. Enable GitHub Pages in repo settings
|
||||||
|
with **Source: GitHub Actions** to activate it.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
`build_website.py` reuses the chapter-ordering logic from `build_epub.py` and
|
||||||
|
ships HTML templates under `scripts/website_templates/`:
|
||||||
|
|
||||||
|
- `page.html.j2` — per-page Jinja2 template with sidebar nav, TOC, prev/next
|
||||||
|
- `tailwind.config.js`, `tailwind.input.css` — config + entry CSS for the
|
||||||
|
Tailwind standalone CLI; the CLI scans the built HTML and produces
|
||||||
|
`site/assets/tailwind.css` with just the utilities actually used
|
||||||
|
- `site.css` — small layer of site-specific styles plus Pygments theme
|
||||||
|
|
||||||
|
The Tailwind CLI binary, Mermaid bundle, and font files are downloaded on
|
||||||
|
first build into `scripts/.vendor-cache/` (gitignored) — see
|
||||||
|
`scripts/vendor_assets.py`.
|
||||||
|
|
||||||
|
Heading anchors are generated using the exact algorithm in
|
||||||
|
`check_cross_references.heading_to_anchor`, so `#anchor` links validated by
|
||||||
|
the pre-commit hook resolve correctly on the rendered site.
|
||||||
|
|||||||
902
scripts/build_website.py
Normal file
902
scripts/build_website.py
Normal file
@@ -0,0 +1,902 @@
|
|||||||
|
#!/usr/bin/env -S uv run --script
|
||||||
|
# /// script
|
||||||
|
# dependencies = ["markdown", "beautifulsoup4", "jinja2"]
|
||||||
|
# ///
|
||||||
|
"""
|
||||||
|
Build a static website from the Claude How-To markdown files.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run scripts/build_website.py
|
||||||
|
uv run scripts/build_website.py --lang vi
|
||||||
|
uv run scripts/build_website.py --output site/ --verbose
|
||||||
|
|
||||||
|
The website renders the existing markdown files as the single source of truth.
|
||||||
|
No content is duplicated — re-running this script regenerates the entire site
|
||||||
|
from the current state of the `.md` files.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
Creates `site/` (or the path passed to `--output`) containing one HTML page
|
||||||
|
per markdown source plus `assets/` with logos and copied images.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Renders the same chapter order as the EPUB build (curriculum order).
|
||||||
|
- Rewrites internal `.md` links to corresponding HTML pages on the site.
|
||||||
|
- Rewrites repo-file/folder references (`.json`, `.sh`, `.py`, etc.) to
|
||||||
|
GitHub source URLs so users can jump to the source on github.com.
|
||||||
|
- Self-hosted Tailwind CSS (compiled via standalone CLI), Inter font, and
|
||||||
|
`mermaid.min.js` — no third-party CDN scripts at runtime.
|
||||||
|
- Light/dark theme toggle, mobile-friendly responsive layout, sidebar.
|
||||||
|
- Hostable as plain static files (e.g. GitHub Pages).
|
||||||
|
|
||||||
|
Vendor assets (Tailwind CLI binary, Mermaid bundle, font files) are downloaded
|
||||||
|
on first build and cached under `scripts/.vendor-cache/`. See
|
||||||
|
`scripts/vendor_assets.py` for details.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import html
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import markdown
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
|
||||||
|
# Make sibling script modules importable regardless of cwd.
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from vendor_assets import (
|
||||||
|
build_tailwind_css,
|
||||||
|
fetch_fonts,
|
||||||
|
fetch_mermaid,
|
||||||
|
write_vendor_manifest,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Configuration
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
REPO_URL = "https://github.com/luongnv89/claude-howto"
|
||||||
|
DEFAULT_BRANCH = "main"
|
||||||
|
|
||||||
|
# Files/dirs that exist in the repo but should not appear on the site.
|
||||||
|
EXCLUDE_DIRS = {
|
||||||
|
".git",
|
||||||
|
".github",
|
||||||
|
".venv",
|
||||||
|
"venv",
|
||||||
|
"env",
|
||||||
|
"node_modules",
|
||||||
|
"__pycache__",
|
||||||
|
".pytest_cache",
|
||||||
|
".ruff_cache",
|
||||||
|
"blog-posts",
|
||||||
|
"openspec",
|
||||||
|
"prompts",
|
||||||
|
".agents",
|
||||||
|
"archive",
|
||||||
|
"local-progress",
|
||||||
|
"promo-video",
|
||||||
|
"slides",
|
||||||
|
".gitissue",
|
||||||
|
".asm-improver",
|
||||||
|
".codex",
|
||||||
|
".opencode",
|
||||||
|
".claude",
|
||||||
|
"site",
|
||||||
|
"scripts",
|
||||||
|
"vi",
|
||||||
|
"zh",
|
||||||
|
"ja",
|
||||||
|
"uk",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Top-level markdown files that should not be rendered as standalone pages.
|
||||||
|
EXCLUDE_TOP_LEVEL = {
|
||||||
|
"CLAUDE.md",
|
||||||
|
"README.backup.md",
|
||||||
|
}
|
||||||
|
|
||||||
|
EXCLUDE_TOP_LEVEL_PREFIXES = ("update-plan",)
|
||||||
|
|
||||||
|
# Match the EPUB chapter ordering.
|
||||||
|
CHAPTER_ORDER: list[tuple[str, str]] = [
|
||||||
|
("README.md", "Introduction"),
|
||||||
|
("LEARNING-ROADMAP.md", "Learning Roadmap"),
|
||||||
|
("QUICK_REFERENCE.md", "Quick Reference"),
|
||||||
|
("claude_concepts_guide.md", "Claude Concepts Guide"),
|
||||||
|
("01-slash-commands", "Slash Commands"),
|
||||||
|
("02-memory", "Memory"),
|
||||||
|
("03-skills", "Skills"),
|
||||||
|
("04-subagents", "Subagents"),
|
||||||
|
("05-mcp", "MCP Protocol"),
|
||||||
|
("06-hooks", "Hooks"),
|
||||||
|
("07-plugins", "Plugins"),
|
||||||
|
("08-checkpoints", "Checkpoints"),
|
||||||
|
("09-advanced-features", "Advanced Features"),
|
||||||
|
("10-cli", "CLI Reference"),
|
||||||
|
("CATALOG.md", "Feature Catalog"),
|
||||||
|
("INDEX.md", "Index"),
|
||||||
|
("STYLE_GUIDE.md", "Style Guide"),
|
||||||
|
("resources.md", "Resources"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WebsiteConfig:
|
||||||
|
"""Configuration for the website builder."""
|
||||||
|
|
||||||
|
root_path: Path
|
||||||
|
output_path: Path
|
||||||
|
repo_url: str = REPO_URL
|
||||||
|
branch: str = DEFAULT_BRANCH
|
||||||
|
site_title: str = "Claude Code How-To Guide"
|
||||||
|
site_subtitle: str = "Master Claude Code in a Weekend"
|
||||||
|
language: str = "en"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PageInfo:
|
||||||
|
"""A single page generated for the site."""
|
||||||
|
|
||||||
|
source: Path # absolute path to the markdown source
|
||||||
|
rel_source: str # path relative to repo root (POSIX style)
|
||||||
|
output_url: str # site-relative URL, e.g. `01-slash-commands/index.html`
|
||||||
|
title: str
|
||||||
|
section: str # the chapter group name (e.g. "Slash Commands")
|
||||||
|
is_section_index: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BuildState:
|
||||||
|
"""Build-time state shared across page rendering."""
|
||||||
|
|
||||||
|
pages: list[PageInfo] = field(default_factory=list)
|
||||||
|
source_to_url: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Logging
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(verbose: bool = False) -> logging.Logger:
|
||||||
|
"""Configure logging for the website builder."""
|
||||||
|
level = logging.DEBUG if verbose else logging.INFO
|
||||||
|
logging.basicConfig(
|
||||||
|
level=level,
|
||||||
|
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
return logging.getLogger("website_builder")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Anchor algorithm (mirrors check_cross_references.heading_to_anchor)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def heading_to_anchor(heading: str) -> str:
|
||||||
|
"""Convert a heading to a GitHub-style anchor.
|
||||||
|
|
||||||
|
Mirrors `scripts/check_cross_references.heading_to_anchor` so the website
|
||||||
|
resolves the same `#anchor` references the validator accepts.
|
||||||
|
"""
|
||||||
|
heading = re.sub(
|
||||||
|
r"[\U0001F000-\U0001FFFF"
|
||||||
|
r"\U00002702-\U000027B0"
|
||||||
|
r"\U0000FE00-\U0000FE0F"
|
||||||
|
r"\U0000200D"
|
||||||
|
r"\U000000A9\U000000AE"
|
||||||
|
r"\U00002000-\U0000206F"
|
||||||
|
r"]",
|
||||||
|
"",
|
||||||
|
heading,
|
||||||
|
)
|
||||||
|
anchor = re.sub(r"[^\w\s-]", "", heading.lower(), flags=re.UNICODE)
|
||||||
|
anchor = anchor.replace(" ", "-")
|
||||||
|
return anchor.rstrip("-")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Source discovery
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def is_excluded_dir(name: str) -> bool:
|
||||||
|
return name.startswith(".") or name in EXCLUDE_DIRS
|
||||||
|
|
||||||
|
|
||||||
|
def collect_folder_markdown(folder: Path) -> list[Path]:
|
||||||
|
"""Return markdown files inside a chapter folder.
|
||||||
|
|
||||||
|
Order: README.md first (if present), then other top-level markdown files
|
||||||
|
sorted alphabetically, then markdown files from non-hidden subfolders.
|
||||||
|
"""
|
||||||
|
files: list[Path] = []
|
||||||
|
readme = folder / "README.md"
|
||||||
|
if readme.exists():
|
||||||
|
files.append(readme)
|
||||||
|
|
||||||
|
files.extend(md for md in sorted(folder.glob("*.md")) if md.name != "README.md")
|
||||||
|
|
||||||
|
for sub in sorted(folder.iterdir()):
|
||||||
|
if sub.is_dir() and not is_excluded_dir(sub.name):
|
||||||
|
files.extend(collect_folder_markdown(sub))
|
||||||
|
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def is_excluded_top_level_markdown(name: str) -> bool:
|
||||||
|
return name in EXCLUDE_TOP_LEVEL or any(
|
||||||
|
name.startswith(prefix) for prefix in EXCLUDE_TOP_LEVEL_PREFIXES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def derive_page_title(md_path: Path, default: str) -> str:
|
||||||
|
"""Pick a page title from the first H1, falling back to filename / default."""
|
||||||
|
try:
|
||||||
|
content = md_path.read_text(encoding="utf-8")
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
return default
|
||||||
|
match = re.search(r"^#\s+(.+?)\s*$", content, flags=re.MULTILINE)
|
||||||
|
if match:
|
||||||
|
return match.group(1).strip()
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def source_to_site_url(rel_source: str) -> str:
|
||||||
|
"""Map `01-slash-commands/README.md` → `01-slash-commands/index.html`."""
|
||||||
|
if rel_source == "README.md":
|
||||||
|
return "index.html"
|
||||||
|
if rel_source.endswith("/README.md"):
|
||||||
|
return rel_source[: -len("README.md")] + "index.html"
|
||||||
|
if rel_source.endswith(".md"):
|
||||||
|
return rel_source[:-3] + ".html"
|
||||||
|
return rel_source
|
||||||
|
|
||||||
|
|
||||||
|
def _disambiguate_url(url: str, used_lower: set[str], rel_source: str) -> str:
|
||||||
|
"""Avoid case-insensitive filesystem collisions (e.g. INDEX.html ↔ index.html).
|
||||||
|
|
||||||
|
macOS/Windows treat `INDEX.html` and `index.html` as the same file. When
|
||||||
|
two source files would resolve to URLs that differ only in case, suffix
|
||||||
|
the second one with the source stem so both pages survive the build.
|
||||||
|
"""
|
||||||
|
if url.lower() not in used_lower:
|
||||||
|
return url
|
||||||
|
parent, sep, leaf = url.rpartition("/")
|
||||||
|
stem, dot, ext = leaf.rpartition(".")
|
||||||
|
src_stem = Path(rel_source).stem.lower()
|
||||||
|
candidate = f"{parent}{sep}{stem}-{src_stem}{dot}{ext}"
|
||||||
|
suffix = 2
|
||||||
|
while candidate.lower() in used_lower:
|
||||||
|
candidate = f"{parent}{sep}{stem}-{src_stem}-{suffix}{dot}{ext}"
|
||||||
|
suffix += 1
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def collect_pages(config: WebsiteConfig, logger: logging.Logger) -> BuildState:
|
||||||
|
"""Walk the configured chapter order and produce a flat list of pages."""
|
||||||
|
state = BuildState()
|
||||||
|
seen: set[str] = set()
|
||||||
|
used_urls: set[str] = set()
|
||||||
|
|
||||||
|
for item, display_name in CHAPTER_ORDER:
|
||||||
|
item_path = config.root_path / item
|
||||||
|
if not item_path.exists():
|
||||||
|
logger.debug(f"Skipping missing chapter target: {item}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if item_path.is_file() and item_path.suffix == ".md":
|
||||||
|
if is_excluded_top_level_markdown(item) or item in seen:
|
||||||
|
continue
|
||||||
|
seen.add(item)
|
||||||
|
page_title = derive_page_title(item_path, display_name)
|
||||||
|
url = _disambiguate_url(source_to_site_url(item), used_urls, item)
|
||||||
|
used_urls.add(url.lower())
|
||||||
|
state.pages.append(
|
||||||
|
PageInfo(
|
||||||
|
source=item_path,
|
||||||
|
rel_source=item,
|
||||||
|
output_url=url,
|
||||||
|
title=page_title,
|
||||||
|
section=display_name,
|
||||||
|
is_section_index=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif item_path.is_dir():
|
||||||
|
folder_files = collect_folder_markdown(item_path)
|
||||||
|
for md in folder_files:
|
||||||
|
rel = md.relative_to(config.root_path).as_posix()
|
||||||
|
if rel in seen:
|
||||||
|
continue
|
||||||
|
seen.add(rel)
|
||||||
|
is_index = md.name == "README.md" and md.parent == item_path
|
||||||
|
title = derive_page_title(md, display_name if is_index else md.stem)
|
||||||
|
url = _disambiguate_url(source_to_site_url(rel), used_urls, rel)
|
||||||
|
used_urls.add(url.lower())
|
||||||
|
state.pages.append(
|
||||||
|
PageInfo(
|
||||||
|
source=md,
|
||||||
|
rel_source=rel,
|
||||||
|
output_url=url,
|
||||||
|
title=title,
|
||||||
|
section=display_name,
|
||||||
|
is_section_index=is_index,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Chapter target is not a file or directory: {item}")
|
||||||
|
|
||||||
|
for md in sorted(config.root_path.glob("*.md")):
|
||||||
|
rel = md.relative_to(config.root_path).as_posix()
|
||||||
|
if is_excluded_top_level_markdown(md.name) or rel in seen:
|
||||||
|
continue
|
||||||
|
seen.add(rel)
|
||||||
|
title_default = md.stem.replace("-", " ").replace("_", " ").title()
|
||||||
|
title = derive_page_title(md, title_default)
|
||||||
|
url = _disambiguate_url(source_to_site_url(rel), used_urls, rel)
|
||||||
|
used_urls.add(url.lower())
|
||||||
|
state.pages.append(
|
||||||
|
PageInfo(
|
||||||
|
source=md,
|
||||||
|
rel_source=rel,
|
||||||
|
output_url=url,
|
||||||
|
title=title,
|
||||||
|
section="Additional Docs",
|
||||||
|
is_section_index=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for page in state.pages:
|
||||||
|
state.source_to_url[page.rel_source] = page.output_url
|
||||||
|
|
||||||
|
logger.info(f"Collected {len(state.pages)} pages across the curriculum")
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Link rewriting
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def is_external(href: str) -> bool:
|
||||||
|
return href.startswith(("http://", "https://", "mailto:", "tel:"))
|
||||||
|
|
||||||
|
|
||||||
|
def relative_link(from_url: str, to_url: str, anchor: str = "") -> str:
|
||||||
|
"""Build a relative URL from `from_url` to `to_url` (both site-relative)."""
|
||||||
|
if from_url == to_url:
|
||||||
|
return anchor or ""
|
||||||
|
from_parts = from_url.split("/")[:-1]
|
||||||
|
to_parts = to_url.split("/")
|
||||||
|
|
||||||
|
common = 0
|
||||||
|
for a, b in zip(from_parts, to_parts, strict=False):
|
||||||
|
if a != b:
|
||||||
|
break
|
||||||
|
common += 1
|
||||||
|
|
||||||
|
ups = [".."] * (len(from_parts) - common)
|
||||||
|
downs = to_parts[common:]
|
||||||
|
parts = ups + downs
|
||||||
|
rel = "/".join(parts) if parts else to_parts[-1]
|
||||||
|
return rel + anchor
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_repo_relative(href: str, page_dir: Path, root_path: Path) -> str | None:
|
||||||
|
"""Resolve `href` relative to `page_dir` and return its repo-relative path."""
|
||||||
|
resolved = (page_dir / href).resolve()
|
||||||
|
try:
|
||||||
|
rel_to_root = resolved.relative_to(root_path)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return rel_to_root.as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
def _github_source_url(
|
||||||
|
config: WebsiteConfig, rel_str: str, *, is_dir: bool, anchor: str = ""
|
||||||
|
) -> str:
|
||||||
|
kind = "tree" if is_dir else "blob"
|
||||||
|
if rel_str == ".":
|
||||||
|
return f"{config.repo_url}/{kind}/{config.branch}{anchor}"
|
||||||
|
return f"{config.repo_url}/{kind}/{config.branch}/{rel_str}{anchor}"
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_anchor(
|
||||||
|
a: object,
|
||||||
|
page: PageInfo,
|
||||||
|
state: BuildState,
|
||||||
|
config: WebsiteConfig,
|
||||||
|
logger: logging.Logger,
|
||||||
|
) -> None:
|
||||||
|
"""Rewrite a single `<a href>` to its site URL or GitHub source URL."""
|
||||||
|
href = a.get("href", "") # type: ignore[attr-defined]
|
||||||
|
if not href or is_external(href) or href.startswith("#"):
|
||||||
|
return
|
||||||
|
|
||||||
|
anchor = ""
|
||||||
|
if "#" in href:
|
||||||
|
href, anchor_part = href.split("#", 1)
|
||||||
|
anchor = "#" + anchor_part
|
||||||
|
if not href:
|
||||||
|
return
|
||||||
|
|
||||||
|
rel_str = _resolve_repo_relative(href, page.source.parent, config.root_path)
|
||||||
|
if rel_str is None:
|
||||||
|
logger.debug(f"Link outside repo skipped: {href} (in {page.rel_source})")
|
||||||
|
return
|
||||||
|
|
||||||
|
candidates = [rel_str]
|
||||||
|
resolved = (page.source.parent / href).resolve()
|
||||||
|
if not rel_str.endswith(".md") and resolved.is_dir():
|
||||||
|
candidates.append(rel_str + "/README.md")
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate in state.source_to_url:
|
||||||
|
a["href"] = relative_link( # type: ignore[index]
|
||||||
|
page.output_url, state.source_to_url[candidate], anchor
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
github_url = _github_source_url(
|
||||||
|
config, rel_str, is_dir=resolved.is_dir(), anchor=anchor
|
||||||
|
)
|
||||||
|
a["href"] = github_url # type: ignore[index]
|
||||||
|
a["target"] = "_blank" # type: ignore[index]
|
||||||
|
a["rel"] = "noopener noreferrer" # type: ignore[index]
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_asset_ref(
|
||||||
|
element: object,
|
||||||
|
attr: str,
|
||||||
|
raw_value: str,
|
||||||
|
page: PageInfo,
|
||||||
|
config: WebsiteConfig,
|
||||||
|
) -> None:
|
||||||
|
"""Rewrite a single asset reference (img.src / source.srcset) to assets/."""
|
||||||
|
if not raw_value or is_external(raw_value):
|
||||||
|
return
|
||||||
|
first = raw_value.split(",", 1)[0].strip().split(" ", 1)[0]
|
||||||
|
if not first or is_external(first):
|
||||||
|
return
|
||||||
|
rel_str = _resolve_repo_relative(first, page.source.parent, config.root_path)
|
||||||
|
if rel_str is None:
|
||||||
|
return
|
||||||
|
target = "assets/" + rel_str
|
||||||
|
element[attr] = relative_link(page.output_url, target) # type: ignore[index]
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite_links(
|
||||||
|
html_content: str,
|
||||||
|
page: PageInfo,
|
||||||
|
state: BuildState,
|
||||||
|
config: WebsiteConfig,
|
||||||
|
logger: logging.Logger,
|
||||||
|
) -> str:
|
||||||
|
"""Rewrite anchor/image hrefs so links resolve correctly on the site."""
|
||||||
|
soup = BeautifulSoup(html_content, "html.parser")
|
||||||
|
|
||||||
|
for a in soup.find_all("a"):
|
||||||
|
_rewrite_anchor(a, page, state, config, logger)
|
||||||
|
|
||||||
|
for img in soup.find_all("img"):
|
||||||
|
_rewrite_asset_ref(img, "src", img.get("src", ""), page, config)
|
||||||
|
if img.get("src"):
|
||||||
|
img["loading"] = "lazy"
|
||||||
|
|
||||||
|
for source in soup.find_all("source"):
|
||||||
|
_rewrite_asset_ref(source, "srcset", source.get("srcset", ""), page, config)
|
||||||
|
|
||||||
|
return str(soup)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Mermaid handling
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
MERMAID_BLOCK_RE = re.compile(r"```mermaid\n(.*?)```", re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
|
def replace_mermaid_blocks(md_content: str) -> str:
|
||||||
|
"""Replace ```mermaid``` fences with `<pre class="mermaid">` for client-side render."""
|
||||||
|
|
||||||
|
def _replace(match: re.Match[str]) -> str:
|
||||||
|
code = match.group(1)
|
||||||
|
return f'<pre class="mermaid">{html.escape(code)}</pre>'
|
||||||
|
|
||||||
|
return MERMAID_BLOCK_RE.sub(_replace, md_content)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Markdown rendering
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def normalise_heading_ids(html_content: str) -> str:
|
||||||
|
"""Force GitHub-style anchor ids on every heading.
|
||||||
|
|
||||||
|
`python-markdown`'s `toc` extension generates its own slug; we re-write them
|
||||||
|
so they match `check_cross_references.heading_to_anchor`.
|
||||||
|
"""
|
||||||
|
soup = BeautifulSoup(html_content, "html.parser")
|
||||||
|
used: dict[str, int] = {}
|
||||||
|
for level in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
||||||
|
for h in soup.find_all(level):
|
||||||
|
text = h.get_text(strip=True)
|
||||||
|
anchor = heading_to_anchor(text)
|
||||||
|
if not anchor:
|
||||||
|
continue
|
||||||
|
count = used.get(anchor, 0)
|
||||||
|
final = anchor if count == 0 else f"{anchor}-{count}"
|
||||||
|
used[anchor] = count + 1
|
||||||
|
h["id"] = final
|
||||||
|
return str(soup)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_toc(html_content: str) -> list[dict[str, str]]:
|
||||||
|
"""Pull H2/H3 headings into a flat list for in-page navigation."""
|
||||||
|
soup = BeautifulSoup(html_content, "html.parser")
|
||||||
|
toc: list[dict[str, str]] = []
|
||||||
|
for h in soup.find_all(["h2", "h3"]):
|
||||||
|
anchor = h.get("id")
|
||||||
|
if not anchor:
|
||||||
|
continue
|
||||||
|
toc.append(
|
||||||
|
{
|
||||||
|
"level": h.name,
|
||||||
|
"text": h.get_text(strip=True),
|
||||||
|
"anchor": anchor,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return toc
|
||||||
|
|
||||||
|
|
||||||
|
def render_markdown(md_content: str) -> str:
|
||||||
|
"""Convert markdown to HTML using the same extensions as the EPUB build."""
|
||||||
|
md_content = replace_mermaid_blocks(md_content)
|
||||||
|
html_content = markdown.markdown(
|
||||||
|
md_content,
|
||||||
|
extensions=["tables", "fenced_code", "codehilite", "toc"],
|
||||||
|
extension_configs={"codehilite": {"guess_lang": False}},
|
||||||
|
)
|
||||||
|
return normalise_heading_ids(html_content)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Asset copying
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
ASSET_EXTENSIONS = {
|
||||||
|
".svg",
|
||||||
|
".png",
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".gif",
|
||||||
|
".webp",
|
||||||
|
".ico",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def copy_assets(
|
||||||
|
config: WebsiteConfig, state: BuildState, logger: logging.Logger
|
||||||
|
) -> None:
|
||||||
|
"""Copy images referenced by any rendered page into `<output>/assets/`."""
|
||||||
|
assets_dir = config.output_path / "assets"
|
||||||
|
assets_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
copied: set[Path] = set()
|
||||||
|
|
||||||
|
for page in state.pages:
|
||||||
|
try:
|
||||||
|
content = page.source.read_text(encoding="utf-8")
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
continue
|
||||||
|
for match in re.finditer(r"!\[[^\]]*\]\(([^)]+)\)", content):
|
||||||
|
src = match.group(1).split(" ", 1)[0]
|
||||||
|
if is_external(src):
|
||||||
|
continue
|
||||||
|
resolved = (page.source.parent / src).resolve()
|
||||||
|
if not resolved.exists() or not resolved.is_file():
|
||||||
|
continue
|
||||||
|
if resolved.suffix.lower() not in ASSET_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rel = resolved.relative_to(config.root_path)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
target = assets_dir / rel
|
||||||
|
if target in copied:
|
||||||
|
continue
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(resolved, target)
|
||||||
|
copied.add(target)
|
||||||
|
|
||||||
|
# Always copy the logo set so the site header can reuse it.
|
||||||
|
logo_dir = config.root_path / "resources" / "logos"
|
||||||
|
if logo_dir.exists():
|
||||||
|
for logo in logo_dir.glob("*.svg"):
|
||||||
|
try:
|
||||||
|
rel = logo.relative_to(config.root_path)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
target = assets_dir / rel
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(logo, target)
|
||||||
|
copied.add(target)
|
||||||
|
|
||||||
|
logger.info(f"Copied {len(copied)} asset file(s) into {assets_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Page rendering
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def build_navigation(state: BuildState, current_url: str) -> list[dict[str, object]]:
|
||||||
|
"""Group pages into the sidebar navigation tree, rooted at `current_url`.
|
||||||
|
|
||||||
|
Each item's `url` is a relative URL from `current_url` so the same nav
|
||||||
|
structure works from any page depth (top-level vs nested chapter folders).
|
||||||
|
"""
|
||||||
|
sections: list[dict[str, object]] = []
|
||||||
|
section_map: dict[str, dict[str, object]] = {}
|
||||||
|
|
||||||
|
for page in state.pages:
|
||||||
|
section = section_map.get(page.section)
|
||||||
|
if section is None:
|
||||||
|
section = {
|
||||||
|
"name": page.section,
|
||||||
|
"items": [],
|
||||||
|
}
|
||||||
|
section_map[page.section] = section
|
||||||
|
sections.append(section)
|
||||||
|
|
||||||
|
items = section["items"]
|
||||||
|
assert isinstance(items, list)
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"title": page.title,
|
||||||
|
"url": relative_link(current_url, page.output_url),
|
||||||
|
"is_current": page.output_url == current_url,
|
||||||
|
"is_index": page.is_section_index,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
def render_pages(
|
||||||
|
config: WebsiteConfig,
|
||||||
|
state: BuildState,
|
||||||
|
env: Environment,
|
||||||
|
logger: logging.Logger,
|
||||||
|
) -> None:
|
||||||
|
"""Render each markdown page into `<output>/<output_url>`."""
|
||||||
|
template = env.get_template("page.html.j2")
|
||||||
|
total = len(state.pages)
|
||||||
|
|
||||||
|
for idx, page in enumerate(state.pages):
|
||||||
|
nav = build_navigation(state, page.output_url)
|
||||||
|
try:
|
||||||
|
md_content = page.source.read_text(encoding="utf-8")
|
||||||
|
except UnicodeDecodeError as e:
|
||||||
|
raise RuntimeError(f"Failed to read {page.source}: {e}") from e
|
||||||
|
|
||||||
|
html_content = render_markdown(md_content)
|
||||||
|
html_content = rewrite_links(html_content, page, state, config, logger)
|
||||||
|
toc = extract_toc(html_content)
|
||||||
|
|
||||||
|
prev_page = state.pages[idx - 1] if idx > 0 else None
|
||||||
|
next_page = state.pages[idx + 1] if idx < total - 1 else None
|
||||||
|
|
||||||
|
rendered = template.render(
|
||||||
|
site_title=config.site_title,
|
||||||
|
site_subtitle=config.site_subtitle,
|
||||||
|
page_title=page.title,
|
||||||
|
section=page.section,
|
||||||
|
content=html_content,
|
||||||
|
toc=toc,
|
||||||
|
nav=nav,
|
||||||
|
current_url=page.output_url,
|
||||||
|
base_path=relative_link(page.output_url, "index.html").rsplit(
|
||||||
|
"index.html", 1
|
||||||
|
)[0],
|
||||||
|
assets_prefix=relative_link(page.output_url, "assets/").rsplit(
|
||||||
|
"assets/", 1
|
||||||
|
)[0]
|
||||||
|
+ "assets/",
|
||||||
|
prev_page=(
|
||||||
|
{
|
||||||
|
"title": prev_page.title,
|
||||||
|
"url": relative_link(page.output_url, prev_page.output_url),
|
||||||
|
}
|
||||||
|
if prev_page
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
next_page=(
|
||||||
|
{
|
||||||
|
"title": next_page.title,
|
||||||
|
"url": relative_link(page.output_url, next_page.output_url),
|
||||||
|
}
|
||||||
|
if next_page
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
github_source_url=f"{config.repo_url}/blob/{config.branch}/{page.rel_source}",
|
||||||
|
repo_url=config.repo_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
out_file = config.output_path / page.output_url
|
||||||
|
out_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_file.write_text(rendered, encoding="utf-8")
|
||||||
|
logger.debug(f"Wrote {out_file}")
|
||||||
|
|
||||||
|
logger.info(f"Rendered {total} HTML page(s) into {config.output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Build orchestration
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def build_website(
|
||||||
|
config: WebsiteConfig,
|
||||||
|
logger: logging.Logger,
|
||||||
|
*,
|
||||||
|
skip_vendor: bool = False,
|
||||||
|
) -> Path:
|
||||||
|
"""Generate the full static site at `config.output_path`.
|
||||||
|
|
||||||
|
``skip_vendor=True`` skips the Tailwind CLI compile and the Mermaid/font
|
||||||
|
downloads — used by tests that don't need network access.
|
||||||
|
"""
|
||||||
|
if not config.root_path.is_dir():
|
||||||
|
raise RuntimeError(f"Root path is not a directory: {config.root_path}")
|
||||||
|
|
||||||
|
config.output_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
template_dir = Path(__file__).parent / "website_templates"
|
||||||
|
env = Environment(
|
||||||
|
loader=FileSystemLoader(str(template_dir)),
|
||||||
|
autoescape=select_autoescape(["html", "xml"]),
|
||||||
|
trim_blocks=True,
|
||||||
|
lstrip_blocks=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
state = collect_pages(config, logger)
|
||||||
|
if not state.pages:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"No markdown pages found under {config.root_path}; "
|
||||||
|
"check that the chapter directories exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
render_pages(config, state, env, logger)
|
||||||
|
copy_assets(config, state, logger)
|
||||||
|
|
||||||
|
# Self-hosted vendor assets — drop all CDN dependencies.
|
||||||
|
assets_dir = config.output_path / "assets"
|
||||||
|
css_source = template_dir / "site.css"
|
||||||
|
if css_source.exists():
|
||||||
|
css_target = assets_dir / "site.css"
|
||||||
|
css_target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(css_source, css_target)
|
||||||
|
|
||||||
|
if skip_vendor:
|
||||||
|
logger.info("Skipping vendor asset fetch (skip_vendor=True)")
|
||||||
|
else:
|
||||||
|
vendor_dir = assets_dir / "vendor"
|
||||||
|
fetch_mermaid(vendor_dir / "mermaid", logger)
|
||||||
|
fonts_css = fetch_fonts(vendor_dir / "fonts", logger)
|
||||||
|
# Run Tailwind LAST so it can scan the rendered HTML for class usage.
|
||||||
|
build_tailwind_css(
|
||||||
|
output_css=assets_dir / "tailwind.css",
|
||||||
|
template_dir=template_dir,
|
||||||
|
site_dir=config.output_path,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
fonts_files = vendor_dir / "fonts" / "files"
|
||||||
|
write_vendor_manifest(
|
||||||
|
vendor_dir,
|
||||||
|
fonts_count=sum(1 for _ in fonts_files.iterdir())
|
||||||
|
if fonts_files.exists()
|
||||||
|
else 0,
|
||||||
|
)
|
||||||
|
logger.debug(f"Fonts CSS: {fonts_css}")
|
||||||
|
|
||||||
|
logger.info(f"Website build complete: {config.output_path}")
|
||||||
|
return config.output_path
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CLI
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Build a static website from Claude How-To markdown files."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--root",
|
||||||
|
"-r",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="Root directory containing markdown files (default: repo root)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
"-o",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="Output directory for the generated site (default: <repo>/site)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--lang",
|
||||||
|
type=str,
|
||||||
|
default="en",
|
||||||
|
choices=["en", "vi", "zh", "ja", "uk"],
|
||||||
|
help="Language code for the source tree (default: en — root markdown)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--repo-url",
|
||||||
|
type=str,
|
||||||
|
default=REPO_URL,
|
||||||
|
help=f"GitHub repository URL for blob links (default: {REPO_URL})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--branch",
|
||||||
|
type=str,
|
||||||
|
default=DEFAULT_BRANCH,
|
||||||
|
help=f"Branch name for GitHub blob links (default: {DEFAULT_BRANCH})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose", "-v", action="store_true", help="Enable verbose logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
repo_root = (args.root or Path(__file__).parent.parent).resolve()
|
||||||
|
|
||||||
|
lang_root_map = {
|
||||||
|
"en": repo_root,
|
||||||
|
"vi": repo_root / "vi",
|
||||||
|
"zh": repo_root / "zh",
|
||||||
|
"ja": repo_root / "ja",
|
||||||
|
"uk": repo_root / "uk",
|
||||||
|
}
|
||||||
|
source_root = lang_root_map[args.lang].resolve()
|
||||||
|
|
||||||
|
default_output = repo_root / ("site" if args.lang == "en" else f"site-{args.lang}")
|
||||||
|
output_path = (args.output or default_output).resolve()
|
||||||
|
|
||||||
|
logger = setup_logging(args.verbose)
|
||||||
|
config = WebsiteConfig(
|
||||||
|
root_path=source_root,
|
||||||
|
output_path=output_path,
|
||||||
|
repo_url=args.repo_url,
|
||||||
|
branch=args.branch,
|
||||||
|
language=args.lang,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
build_website(config, logger)
|
||||||
|
print(f"Successfully built website at: {output_path}")
|
||||||
|
return 0
|
||||||
|
except (OSError, RuntimeError) as exc:
|
||||||
|
logger.error(f"Build failed: {exc}")
|
||||||
|
return 1
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.warning("Build interrupted by user")
|
||||||
|
return 130
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -53,7 +53,7 @@ URL_RE = re.compile(r"https?://[a-zA-Z0-9][a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+
|
|||||||
|
|
||||||
def is_skipped(url: str) -> bool:
|
def is_skipped(url: str) -> bool:
|
||||||
try:
|
try:
|
||||||
domain = url.split("/")[2]
|
domain = url.split("/", 3)[2].split(":", 1)[0]
|
||||||
except IndexError:
|
except IndexError:
|
||||||
return True # malformed URL
|
return True # malformed URL
|
||||||
if any(skip == domain or domain.endswith("." + skip) for skip in SKIP_DOMAINS):
|
if any(skip == domain or domain.endswith("." + skip) for skip in SKIP_DOMAINS):
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ dependencies = [
|
|||||||
"httpx",
|
"httpx",
|
||||||
"pillow",
|
"pillow",
|
||||||
"tenacity",
|
"tenacity",
|
||||||
|
"jinja2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# Core dependencies for build_epub.py
|
# Core dependencies for build_epub.py and build_website.py
|
||||||
ebooklib==0.18
|
ebooklib==0.18
|
||||||
markdown==3.7
|
markdown==3.7
|
||||||
beautifulsoup4==4.12.3
|
beautifulsoup4==4.12.3
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
pillow==11.1.0
|
pillow==11.1.0
|
||||||
tenacity==9.0.0
|
tenacity==9.0.0
|
||||||
|
jinja2==3.1.4
|
||||||
|
|||||||
520
scripts/tests/test_build_website.py
Normal file
520
scripts/tests/test_build_website.py
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
"""Tests for the static website builder."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from build_website import (
|
||||||
|
BuildState,
|
||||||
|
PageInfo,
|
||||||
|
WebsiteConfig,
|
||||||
|
_disambiguate_url,
|
||||||
|
build_website,
|
||||||
|
collect_folder_markdown,
|
||||||
|
collect_pages,
|
||||||
|
derive_page_title,
|
||||||
|
heading_to_anchor,
|
||||||
|
is_excluded_dir,
|
||||||
|
is_excluded_top_level_markdown,
|
||||||
|
relative_link,
|
||||||
|
render_markdown,
|
||||||
|
replace_mermaid_blocks,
|
||||||
|
rewrite_links,
|
||||||
|
source_to_site_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Fixtures
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def site_root(tmp_path: Path) -> Path:
|
||||||
|
"""Create a minimal repo-like tree the builder can render."""
|
||||||
|
(tmp_path / "README.md").write_text(
|
||||||
|
"<picture>\n"
|
||||||
|
' <source media="(prefers-color-scheme: dark)" '
|
||||||
|
'srcset="resources/logos/claude-howto-logo-dark.svg">\n'
|
||||||
|
' <img alt="Claude How To" src="resources/logos/claude-howto-logo.svg">\n'
|
||||||
|
"</picture>\n\n"
|
||||||
|
"# Home Page\n\nWelcome. See [Slash Commands](01-slash-commands/README.md).\n"
|
||||||
|
"Also check [script](scripts/build.sh) and the [logo](resources/logos/logo.svg).\n"
|
||||||
|
)
|
||||||
|
(tmp_path / "LEARNING-ROADMAP.md").write_text(
|
||||||
|
"# Learning Roadmap\n\nLink back to [Home](README.md#home-page).\n"
|
||||||
|
)
|
||||||
|
(tmp_path / "CONTRIBUTING.md").write_text("# Contributing\n\nHelp improve docs.")
|
||||||
|
(tmp_path / "CLAUDE.md").write_text("# Internal Agent Notes\n")
|
||||||
|
(tmp_path / "update-plan-2026-05-02.md").write_text("# Temporary Plan\n")
|
||||||
|
|
||||||
|
sc = tmp_path / "01-slash-commands"
|
||||||
|
sc.mkdir()
|
||||||
|
(sc / "README.md").write_text(
|
||||||
|
"# Slash Commands\n\nMermaid time:\n\n```mermaid\nflowchart LR\nA-->B\n```\n\n"
|
||||||
|
"See [example](example.md).\n"
|
||||||
|
)
|
||||||
|
(sc / "example.md").write_text("# Example\n\nGo back to [overview](README.md).\n")
|
||||||
|
|
||||||
|
# Non-markdown repo files
|
||||||
|
scripts_dir = tmp_path / "scripts"
|
||||||
|
scripts_dir.mkdir()
|
||||||
|
(scripts_dir / "build.sh").write_text("#!/bin/bash\necho hi\n")
|
||||||
|
|
||||||
|
logos = tmp_path / "resources" / "logos"
|
||||||
|
logos.mkdir(parents=True)
|
||||||
|
(logos / "logo.svg").write_text("<svg></svg>")
|
||||||
|
(logos / "claude-howto-logo.svg").write_text("<svg></svg>")
|
||||||
|
(logos / "claude-howto-logo-dark.svg").write_text("<svg></svg>")
|
||||||
|
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def logger() -> logging.Logger:
|
||||||
|
return logging.getLogger("test_build_website")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# heading_to_anchor parity with check_cross_references
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeadingToAnchor:
|
||||||
|
def test_simple_title(self) -> None:
|
||||||
|
assert heading_to_anchor("Hello World") == "hello-world"
|
||||||
|
|
||||||
|
def test_punctuation_removed(self) -> None:
|
||||||
|
assert heading_to_anchor("What's Next?") == "whats-next"
|
||||||
|
|
||||||
|
def test_unicode_preserved(self) -> None:
|
||||||
|
assert heading_to_anchor("Hướng dẫn") == "hướng-dẫn"
|
||||||
|
|
||||||
|
def test_emoji_stripped(self) -> None:
|
||||||
|
# The validator strips emoji; we mirror that exactly.
|
||||||
|
assert heading_to_anchor("🔥 Trending") == "-trending"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# source_to_site_url
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestSourceToSiteUrl:
|
||||||
|
def test_root_readme_maps_to_index(self) -> None:
|
||||||
|
assert source_to_site_url("README.md") == "index.html"
|
||||||
|
|
||||||
|
def test_folder_readme_maps_to_folder_index(self) -> None:
|
||||||
|
assert (
|
||||||
|
source_to_site_url("01-slash-commands/README.md")
|
||||||
|
== "01-slash-commands/index.html"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_other_markdown_uses_html_extension(self) -> None:
|
||||||
|
assert (
|
||||||
|
source_to_site_url("01-slash-commands/example.md")
|
||||||
|
== "01-slash-commands/example.html"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDisambiguateUrl:
|
||||||
|
def test_no_collision_passes_through(self) -> None:
|
||||||
|
used: set[str] = {"foo.html"}
|
||||||
|
assert _disambiguate_url("bar.html", used, "bar.md") == "bar.html"
|
||||||
|
|
||||||
|
def test_case_insensitive_collision_disambiguated(self) -> None:
|
||||||
|
# README → index.html lands first; INDEX.md (case-insensitive collision)
|
||||||
|
# must get a suffix on macOS / Windows filesystems.
|
||||||
|
used: set[str] = {"index.html"}
|
||||||
|
result = _disambiguate_url("INDEX.html", used, "INDEX.md")
|
||||||
|
assert result.lower() != "index.html"
|
||||||
|
assert result.endswith(".html")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# relative_link
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestRelativeLink:
|
||||||
|
def test_same_directory(self) -> None:
|
||||||
|
assert relative_link("01/index.html", "01/example.html") == "example.html"
|
||||||
|
|
||||||
|
def test_anchor_appended(self) -> None:
|
||||||
|
assert (
|
||||||
|
relative_link("01/index.html", "02/index.html", "#intro")
|
||||||
|
== "../02/index.html#intro"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_self_link_returns_anchor_only(self) -> None:
|
||||||
|
assert relative_link("01/index.html", "01/index.html", "#section") == "#section"
|
||||||
|
|
||||||
|
def test_parent_directory(self) -> None:
|
||||||
|
assert relative_link("01/index.html", "index.html") == "../index.html"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# is_excluded_dir
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsExcludedDir:
|
||||||
|
def test_hidden_dirs_excluded(self) -> None:
|
||||||
|
assert is_excluded_dir(".git") is True
|
||||||
|
|
||||||
|
def test_known_dir_excluded(self) -> None:
|
||||||
|
assert is_excluded_dir("node_modules") is True
|
||||||
|
|
||||||
|
def test_chapter_dir_kept(self) -> None:
|
||||||
|
assert is_excluded_dir("01-slash-commands") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsExcludedTopLevelMarkdown:
|
||||||
|
def test_internal_agent_file_excluded(self) -> None:
|
||||||
|
assert is_excluded_top_level_markdown("CLAUDE.md") is True
|
||||||
|
|
||||||
|
def test_temporary_update_plan_excluded(self) -> None:
|
||||||
|
assert is_excluded_top_level_markdown("update-plan-2026-05-02.md") is True
|
||||||
|
|
||||||
|
def test_project_doc_included(self) -> None:
|
||||||
|
assert is_excluded_top_level_markdown("CONTRIBUTING.md") is False
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# collect_folder_markdown
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestCollectFolderMarkdown:
|
||||||
|
def test_readme_first(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "b.md").write_text("# B")
|
||||||
|
(tmp_path / "README.md").write_text("# Readme")
|
||||||
|
(tmp_path / "a.md").write_text("# A")
|
||||||
|
files = collect_folder_markdown(tmp_path)
|
||||||
|
assert [f.name for f in files] == ["README.md", "a.md", "b.md"]
|
||||||
|
|
||||||
|
def test_skips_hidden_subdirs(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "README.md").write_text("# R")
|
||||||
|
hidden = tmp_path / ".cache"
|
||||||
|
hidden.mkdir()
|
||||||
|
(hidden / "junk.md").write_text("# junk")
|
||||||
|
files = collect_folder_markdown(tmp_path)
|
||||||
|
assert [f.name for f in files] == ["README.md"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCollectPages:
|
||||||
|
def test_additional_top_level_docs_are_collected(
|
||||||
|
self, site_root: Path, logger: logging.Logger
|
||||||
|
) -> None:
|
||||||
|
state = collect_pages(
|
||||||
|
WebsiteConfig(root_path=site_root, output_path=site_root / "site"), logger
|
||||||
|
)
|
||||||
|
assert "CONTRIBUTING.md" in state.source_to_url
|
||||||
|
assert state.source_to_url["CONTRIBUTING.md"] == "CONTRIBUTING.html"
|
||||||
|
assert "CLAUDE.md" not in state.source_to_url
|
||||||
|
assert "update-plan-2026-05-02.md" not in state.source_to_url
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# derive_page_title
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestDerivePageTitle:
|
||||||
|
def test_uses_h1(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "f.md"
|
||||||
|
f.write_text("Some intro\n# The Title\nBody")
|
||||||
|
assert derive_page_title(f, "Default") == "The Title"
|
||||||
|
|
||||||
|
def test_falls_back_when_no_h1(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "f.md"
|
||||||
|
f.write_text("No heading here")
|
||||||
|
assert derive_page_title(f, "Default") == "Default"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# replace_mermaid_blocks
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestReplaceMermaidBlocks:
|
||||||
|
def test_replaces_fence(self) -> None:
|
||||||
|
md = "Before\n\n```mermaid\nflowchart LR\nA-->B\n```\n\nAfter"
|
||||||
|
out = replace_mermaid_blocks(md)
|
||||||
|
assert '<pre class="mermaid">' in out
|
||||||
|
assert "flowchart LR" in out
|
||||||
|
assert "```mermaid" not in out
|
||||||
|
|
||||||
|
def test_escapes_html(self) -> None:
|
||||||
|
md = "```mermaid\nA --> B<C>\n```\n"
|
||||||
|
out = replace_mermaid_blocks(md)
|
||||||
|
assert "<C>" in out
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# render_markdown
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderMarkdown:
|
||||||
|
def test_heading_gets_github_anchor(self) -> None:
|
||||||
|
html_content = render_markdown("# Hello World\n\nBody")
|
||||||
|
assert 'id="hello-world"' in html_content
|
||||||
|
|
||||||
|
def test_duplicate_headings_get_suffix(self) -> None:
|
||||||
|
html_content = render_markdown("# Hi\n\n# Hi\n")
|
||||||
|
assert 'id="hi"' in html_content
|
||||||
|
assert 'id="hi-1"' in html_content
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# rewrite_links
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestRewriteLinks:
|
||||||
|
def _state(self) -> BuildState:
|
||||||
|
state = BuildState()
|
||||||
|
state.source_to_url = {
|
||||||
|
"README.md": "index.html",
|
||||||
|
"01-slash-commands/README.md": "01-slash-commands/index.html",
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
|
||||||
|
def _config(self, root: Path) -> WebsiteConfig:
|
||||||
|
return WebsiteConfig(
|
||||||
|
root_path=root,
|
||||||
|
output_path=root / "out",
|
||||||
|
repo_url="https://github.com/example/repo",
|
||||||
|
branch="main",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_internal_markdown_link_rewritten(
|
||||||
|
self, tmp_path: Path, logger: logging.Logger
|
||||||
|
) -> None:
|
||||||
|
(tmp_path / "README.md").write_text("# Home")
|
||||||
|
(tmp_path / "01-slash-commands").mkdir()
|
||||||
|
(tmp_path / "01-slash-commands" / "README.md").write_text("# Slash")
|
||||||
|
page = PageInfo(
|
||||||
|
source=tmp_path / "README.md",
|
||||||
|
rel_source="README.md",
|
||||||
|
output_url="index.html",
|
||||||
|
title="Home",
|
||||||
|
section="Introduction",
|
||||||
|
is_section_index=True,
|
||||||
|
)
|
||||||
|
html_in = '<a href="01-slash-commands/README.md">go</a>'
|
||||||
|
out = rewrite_links(
|
||||||
|
html_in, page, self._state(), self._config(tmp_path), logger
|
||||||
|
)
|
||||||
|
assert "01-slash-commands/index.html" in out
|
||||||
|
assert ".md" not in out
|
||||||
|
|
||||||
|
def test_anchor_preserved(self, tmp_path: Path, logger: logging.Logger) -> None:
|
||||||
|
(tmp_path / "README.md").write_text("# Home")
|
||||||
|
(tmp_path / "01-slash-commands").mkdir()
|
||||||
|
(tmp_path / "01-slash-commands" / "README.md").write_text("# Slash")
|
||||||
|
page = PageInfo(
|
||||||
|
source=tmp_path / "README.md",
|
||||||
|
rel_source="README.md",
|
||||||
|
output_url="index.html",
|
||||||
|
title="Home",
|
||||||
|
section="Introduction",
|
||||||
|
is_section_index=True,
|
||||||
|
)
|
||||||
|
html_in = '<a href="01-slash-commands/README.md#run">go</a>'
|
||||||
|
out = rewrite_links(
|
||||||
|
html_in, page, self._state(), self._config(tmp_path), logger
|
||||||
|
)
|
||||||
|
assert "#run" in out
|
||||||
|
|
||||||
|
def test_non_markdown_link_uses_github_blob(
|
||||||
|
self, tmp_path: Path, logger: logging.Logger
|
||||||
|
) -> None:
|
||||||
|
(tmp_path / "scripts").mkdir()
|
||||||
|
(tmp_path / "scripts" / "build.sh").write_text("#!/bin/bash")
|
||||||
|
(tmp_path / "README.md").write_text("# Home")
|
||||||
|
page = PageInfo(
|
||||||
|
source=tmp_path / "README.md",
|
||||||
|
rel_source="README.md",
|
||||||
|
output_url="index.html",
|
||||||
|
title="Home",
|
||||||
|
section="Introduction",
|
||||||
|
is_section_index=True,
|
||||||
|
)
|
||||||
|
html_in = '<a href="scripts/build.sh">script</a>'
|
||||||
|
out = rewrite_links(
|
||||||
|
html_in, page, self._state(), self._config(tmp_path), logger
|
||||||
|
)
|
||||||
|
assert "github.com/example/repo/blob/main/scripts/build.sh" in out
|
||||||
|
assert 'target="_blank"' in out
|
||||||
|
|
||||||
|
def test_repo_directory_link_uses_github_tree(
|
||||||
|
self, tmp_path: Path, logger: logging.Logger
|
||||||
|
) -> None:
|
||||||
|
(tmp_path / "scripts").mkdir()
|
||||||
|
(tmp_path / "README.md").write_text("# Home")
|
||||||
|
page = PageInfo(
|
||||||
|
source=tmp_path / "README.md",
|
||||||
|
rel_source="README.md",
|
||||||
|
output_url="index.html",
|
||||||
|
title="Home",
|
||||||
|
section="Introduction",
|
||||||
|
is_section_index=True,
|
||||||
|
)
|
||||||
|
html_in = '<a href="scripts/">scripts</a>'
|
||||||
|
out = rewrite_links(
|
||||||
|
html_in, page, self._state(), self._config(tmp_path), logger
|
||||||
|
)
|
||||||
|
assert "github.com/example/repo/tree/main/scripts" in out
|
||||||
|
assert "github.com/example/repo/blob/main/scripts" not in out
|
||||||
|
|
||||||
|
def test_repo_root_link_uses_github_tree(
|
||||||
|
self, tmp_path: Path, logger: logging.Logger
|
||||||
|
) -> None:
|
||||||
|
(tmp_path / "README.md").write_text("# Home")
|
||||||
|
page = PageInfo(
|
||||||
|
source=tmp_path / "README.md",
|
||||||
|
rel_source="README.md",
|
||||||
|
output_url="index.html",
|
||||||
|
title="Home",
|
||||||
|
section="Introduction",
|
||||||
|
is_section_index=True,
|
||||||
|
)
|
||||||
|
html_in = '<a href=".">repo root</a>'
|
||||||
|
out = rewrite_links(
|
||||||
|
html_in, page, self._state(), self._config(tmp_path), logger
|
||||||
|
)
|
||||||
|
assert "github.com/example/repo/tree/main" in out
|
||||||
|
assert "github.com/example/repo/blob/main/." not in out
|
||||||
|
|
||||||
|
def test_external_link_left_alone(
|
||||||
|
self, tmp_path: Path, logger: logging.Logger
|
||||||
|
) -> None:
|
||||||
|
page = PageInfo(
|
||||||
|
source=tmp_path / "README.md",
|
||||||
|
rel_source="README.md",
|
||||||
|
output_url="index.html",
|
||||||
|
title="Home",
|
||||||
|
section="Introduction",
|
||||||
|
is_section_index=True,
|
||||||
|
)
|
||||||
|
(tmp_path / "README.md").write_text("# Home")
|
||||||
|
html_in = '<a href="https://anthropic.com">site</a>'
|
||||||
|
out = rewrite_links(
|
||||||
|
html_in, page, self._state(), self._config(tmp_path), logger
|
||||||
|
)
|
||||||
|
assert 'href="https://anthropic.com"' in out
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Full build smoke test
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildWebsite:
|
||||||
|
def test_smoke_build(self, site_root: Path, logger: logging.Logger) -> None:
|
||||||
|
out_dir = site_root / "site"
|
||||||
|
config = WebsiteConfig(
|
||||||
|
root_path=site_root,
|
||||||
|
output_path=out_dir,
|
||||||
|
repo_url="https://github.com/example/repo",
|
||||||
|
branch="main",
|
||||||
|
)
|
||||||
|
|
||||||
|
build_website(config, logger, skip_vendor=True)
|
||||||
|
|
||||||
|
# Index page rendered
|
||||||
|
index = out_dir / "index.html"
|
||||||
|
assert index.exists()
|
||||||
|
index_html = index.read_text(encoding="utf-8")
|
||||||
|
assert "Home Page" in index_html
|
||||||
|
assert "01-slash-commands/index.html" in index_html
|
||||||
|
assert "CONTRIBUTING.html" in index_html
|
||||||
|
# Non-markdown link rewritten to GitHub
|
||||||
|
assert "github.com/example/repo/blob/main/scripts/build.sh" in index_html
|
||||||
|
# <source srcset> inside <picture> rewritten to point at assets/
|
||||||
|
assert 'srcset="assets/resources/logos/' in index_html
|
||||||
|
|
||||||
|
# Additional top-level docs rendered
|
||||||
|
assert (out_dir / "CONTRIBUTING.html").exists()
|
||||||
|
assert not (out_dir / "CLAUDE.html").exists()
|
||||||
|
assert not (out_dir / "update-plan-2026-05-02.html").exists()
|
||||||
|
|
||||||
|
# Folder index rendered
|
||||||
|
sc_index = out_dir / "01-slash-commands" / "index.html"
|
||||||
|
assert sc_index.exists()
|
||||||
|
sc_html = sc_index.read_text(encoding="utf-8")
|
||||||
|
# Mermaid block became a <pre class="mermaid"> block
|
||||||
|
assert '<pre class="mermaid">' in sc_html
|
||||||
|
# Sibling markdown link rewritten
|
||||||
|
assert "example.html" in sc_html
|
||||||
|
|
||||||
|
# Example page rendered
|
||||||
|
example_page = out_dir / "01-slash-commands" / "example.html"
|
||||||
|
assert example_page.exists()
|
||||||
|
example_html = example_page.read_text(encoding="utf-8")
|
||||||
|
# Back-link to README rewrites to index.html
|
||||||
|
assert "index.html" in example_html
|
||||||
|
|
||||||
|
# Stylesheet copied into assets
|
||||||
|
assert (out_dir / "assets" / "site.css").exists()
|
||||||
|
# Logo copied into assets
|
||||||
|
assert (out_dir / "assets" / "resources" / "logos" / "logo.svg").exists()
|
||||||
|
# Template no longer references third-party CDNs.
|
||||||
|
for hostile in (
|
||||||
|
"cdn.tailwindcss.com",
|
||||||
|
"cdn.jsdelivr.net",
|
||||||
|
"fonts.googleapis.com",
|
||||||
|
):
|
||||||
|
assert (
|
||||||
|
hostile not in index_html
|
||||||
|
), f"Built HTML still references {hostile} — CDN should be self-hosted"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# vendor_assets module smoke test
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestVendorAssets:
|
||||||
|
def test_module_exports(self) -> None:
|
||||||
|
"""vendor_assets exposes the API build_website depends on."""
|
||||||
|
import vendor_assets
|
||||||
|
|
||||||
|
for attr in (
|
||||||
|
"build_tailwind_css",
|
||||||
|
"fetch_mermaid",
|
||||||
|
"fetch_fonts",
|
||||||
|
"write_vendor_manifest",
|
||||||
|
"ensure_tailwind_binary",
|
||||||
|
"TAILWIND_VERSION",
|
||||||
|
"MERMAID_VERSION",
|
||||||
|
):
|
||||||
|
assert hasattr(vendor_assets, attr), f"missing {attr}"
|
||||||
|
|
||||||
|
def test_detect_tailwind_asset_name(self) -> None:
|
||||||
|
"""Platform detection returns one of the known asset names."""
|
||||||
|
from vendor_assets import _detect_tailwind_asset_name
|
||||||
|
|
||||||
|
known = {
|
||||||
|
"tailwindcss-macos-arm64",
|
||||||
|
"tailwindcss-macos-x64",
|
||||||
|
"tailwindcss-linux-arm64",
|
||||||
|
"tailwindcss-linux-armv7",
|
||||||
|
"tailwindcss-linux-x64",
|
||||||
|
"tailwindcss-windows-x64.exe",
|
||||||
|
}
|
||||||
|
assert _detect_tailwind_asset_name() in known
|
||||||
|
|
||||||
|
def test_download_rejects_non_http_scheme(self, tmp_path: Path) -> None:
|
||||||
|
"""The _download helper refuses file:/ftp:/etc. — defense-in-depth."""
|
||||||
|
from vendor_assets import _download
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="non-HTTP URL"):
|
||||||
|
_download("file:///etc/passwd", tmp_path / "out.bin")
|
||||||
209
scripts/vendor_assets.py
Normal file
209
scripts/vendor_assets.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"""Download and self-host third-party assets (Tailwind, Mermaid, Inter font).
|
||||||
|
|
||||||
|
Replaces the previous CDN-based approach so the rendered site has no external
|
||||||
|
runtime dependencies. Assets are cached under `scripts/.vendor-cache/` and
|
||||||
|
copied into the built site's `assets/vendor/` directory.
|
||||||
|
|
||||||
|
The Tailwind standalone CLI (a Go binary, no Node.js required) is downloaded
|
||||||
|
on first use and reused thereafter. We pin to Tailwind v3 because the templates
|
||||||
|
use v3-style runtime configuration; Tailwind v4 deprecated that config format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import subprocess # nosec B404 — used only to invoke the bundled Tailwind CLI
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TAILWIND_VERSION = "v3.4.19"
|
||||||
|
MERMAID_VERSION = "10"
|
||||||
|
MERMAID_URL = (
|
||||||
|
f"https://cdn.jsdelivr.net/npm/mermaid@{MERMAID_VERSION}/dist/mermaid.min.js"
|
||||||
|
)
|
||||||
|
GOOGLE_FONTS_CSS_URL = (
|
||||||
|
"https://fonts.googleapis.com/css2?"
|
||||||
|
"family=Inter:wght@400;500;600;700;800"
|
||||||
|
"&family=JetBrains+Mono:wght@400;500;600"
|
||||||
|
"&display=swap"
|
||||||
|
)
|
||||||
|
GOOGLE_FONTS_UA = (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
|
||||||
|
VENDOR_CACHE_DIR_NAME = ".vendor-cache"
|
||||||
|
TAILWIND_BIN_NAME = "tailwindcss"
|
||||||
|
|
||||||
|
|
||||||
|
def _vendor_cache_dir() -> Path:
|
||||||
|
return Path(__file__).parent / VENDOR_CACHE_DIR_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_tailwind_asset_name() -> str:
|
||||||
|
system = platform.system().lower()
|
||||||
|
machine = platform.machine().lower()
|
||||||
|
if system == "darwin":
|
||||||
|
return (
|
||||||
|
"tailwindcss-macos-arm64" if machine == "arm64" else "tailwindcss-macos-x64"
|
||||||
|
)
|
||||||
|
if system == "linux":
|
||||||
|
if machine in ("aarch64", "arm64"):
|
||||||
|
return "tailwindcss-linux-arm64"
|
||||||
|
if machine.startswith("armv7"):
|
||||||
|
return "tailwindcss-linux-armv7"
|
||||||
|
return "tailwindcss-linux-x64"
|
||||||
|
if system == "windows":
|
||||||
|
return "tailwindcss-windows-x64.exe"
|
||||||
|
raise RuntimeError(f"Unsupported platform for Tailwind CLI: {system}/{machine}")
|
||||||
|
|
||||||
|
|
||||||
|
def _download(url: str, dest: Path, headers: dict[str, str] | None = None) -> None:
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not url.startswith(("http://", "https://")):
|
||||||
|
raise ValueError(f"Refusing to fetch non-HTTP URL: {url}")
|
||||||
|
req = urllib.request.Request(url, headers=headers or {})
|
||||||
|
with urllib.request.urlopen(req) as resp, dest.open("wb") as out: # nosec B310 — scheme validated above
|
||||||
|
shutil.copyfileobj(resp, out)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_tailwind_binary(logger: logging.Logger) -> Path:
|
||||||
|
"""Download the Tailwind standalone CLI if not already cached."""
|
||||||
|
cache = _vendor_cache_dir()
|
||||||
|
bin_path = cache / TAILWIND_BIN_NAME
|
||||||
|
if bin_path.exists() and os.access(bin_path, os.X_OK):
|
||||||
|
return bin_path
|
||||||
|
|
||||||
|
asset = _detect_tailwind_asset_name()
|
||||||
|
url = f"https://github.com/tailwindlabs/tailwindcss/releases/download/{TAILWIND_VERSION}/{asset}"
|
||||||
|
logger.info(f"Downloading Tailwind CLI {TAILWIND_VERSION} ({asset})")
|
||||||
|
_download(url, bin_path)
|
||||||
|
bin_path.chmod(bin_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||||
|
return bin_path
|
||||||
|
|
||||||
|
|
||||||
|
def build_tailwind_css(
|
||||||
|
output_css: Path,
|
||||||
|
template_dir: Path,
|
||||||
|
site_dir: Path,
|
||||||
|
logger: logging.Logger,
|
||||||
|
) -> None:
|
||||||
|
"""Compile a single CSS file using the Tailwind standalone CLI."""
|
||||||
|
bin_path = ensure_tailwind_binary(logger)
|
||||||
|
config = template_dir / "tailwind.config.js"
|
||||||
|
input_css = template_dir / "tailwind.input.css"
|
||||||
|
if not config.exists():
|
||||||
|
raise RuntimeError(f"Tailwind config not found: {config}")
|
||||||
|
if not input_css.exists():
|
||||||
|
raise RuntimeError(f"Tailwind input CSS not found: {input_css}")
|
||||||
|
|
||||||
|
output_css.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cmd = [
|
||||||
|
str(bin_path),
|
||||||
|
"--config",
|
||||||
|
str(config),
|
||||||
|
"--input",
|
||||||
|
str(input_css),
|
||||||
|
"--output",
|
||||||
|
str(output_css),
|
||||||
|
"--minify",
|
||||||
|
]
|
||||||
|
logger.info(f"Compiling Tailwind CSS → {output_css}")
|
||||||
|
# Run from the project root so relative `content` globs in the config
|
||||||
|
# resolve against the built site directory.
|
||||||
|
cwd = site_dir.parent if site_dir.parent.exists() else Path.cwd()
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["TAILWIND_SITE_DIR"] = str(site_dir)
|
||||||
|
env["TAILWIND_TEMPLATE_DIR"] = str(template_dir)
|
||||||
|
result = subprocess.run( # nosec B603 — cmd is a fixed argv list, no shell, no user input
|
||||||
|
cmd,
|
||||||
|
cwd=str(cwd),
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Tailwind CLI failed (exit {result.returncode}):\n{result.stderr}"
|
||||||
|
)
|
||||||
|
if result.stderr.strip():
|
||||||
|
logger.debug(f"Tailwind CLI: {result.stderr.strip()}")
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_mermaid(target_dir: Path, logger: logging.Logger) -> Path:
|
||||||
|
"""Download Mermaid's UMD bundle into `target_dir` and return its path."""
|
||||||
|
cache = _vendor_cache_dir() / "mermaid"
|
||||||
|
cache.mkdir(parents=True, exist_ok=True)
|
||||||
|
cached = cache / f"mermaid-{MERMAID_VERSION}.min.js"
|
||||||
|
if not cached.exists():
|
||||||
|
logger.info(f"Downloading Mermaid {MERMAID_VERSION}")
|
||||||
|
_download(MERMAID_URL, cached)
|
||||||
|
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = target_dir / "mermaid.min.js"
|
||||||
|
shutil.copy2(cached, target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
_FONT_URL_RE = re.compile(r"url\((https://fonts\.gstatic\.com/[^)]+)\)")
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_fonts(target_dir: Path, logger: logging.Logger) -> Path:
|
||||||
|
"""Download Google Fonts CSS + WOFF2 files, rewrite URLs to relative paths."""
|
||||||
|
cache = _vendor_cache_dir() / "fonts"
|
||||||
|
cache.mkdir(parents=True, exist_ok=True)
|
||||||
|
css_cache = cache / "fonts.css"
|
||||||
|
files_cache = cache / "files"
|
||||||
|
files_cache.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if not css_cache.exists():
|
||||||
|
logger.info("Downloading Google Fonts CSS")
|
||||||
|
_download(
|
||||||
|
GOOGLE_FONTS_CSS_URL, css_cache, headers={"User-Agent": GOOGLE_FONTS_UA}
|
||||||
|
)
|
||||||
|
|
||||||
|
css = css_cache.read_text(encoding="utf-8")
|
||||||
|
url_map: dict[str, str] = {}
|
||||||
|
for url in _FONT_URL_RE.findall(css):
|
||||||
|
# Stable filename derived from the gstatic path so duplicates collapse.
|
||||||
|
name = url.rsplit("/", 1)[-1]
|
||||||
|
local = files_cache / name
|
||||||
|
if not local.exists():
|
||||||
|
logger.debug(f"Fetching font file: {name}")
|
||||||
|
_download(url, local)
|
||||||
|
url_map[url] = f"files/{name}"
|
||||||
|
|
||||||
|
rewritten = css
|
||||||
|
for url, rel in url_map.items():
|
||||||
|
rewritten = rewritten.replace(url, rel)
|
||||||
|
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(target_dir / "fonts.css").write_text(rewritten, encoding="utf-8")
|
||||||
|
|
||||||
|
target_files = target_dir / "files"
|
||||||
|
target_files.mkdir(parents=True, exist_ok=True)
|
||||||
|
for f in files_cache.iterdir():
|
||||||
|
if f.is_file():
|
||||||
|
shutil.copy2(f, target_files / f.name)
|
||||||
|
return target_dir / "fonts.css"
|
||||||
|
|
||||||
|
|
||||||
|
def write_vendor_manifest(target_dir: Path, fonts_count: int) -> None:
|
||||||
|
"""Record what was vendored — useful for debugging."""
|
||||||
|
manifest = {
|
||||||
|
"tailwind": TAILWIND_VERSION,
|
||||||
|
"mermaid": MERMAID_VERSION,
|
||||||
|
"fonts": {"family": ["Inter", "JetBrains Mono"], "files": fonts_count},
|
||||||
|
}
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(target_dir / "manifest.json").write_text(
|
||||||
|
json.dumps(manifest, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
253
scripts/website_templates/page.html.j2
Normal file
253
scripts/website_templates/page.html.j2
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="scroll-smooth">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>{{ page_title }} — {{ site_title }}</title>
|
||||||
|
<meta name="description" content="{{ site_subtitle }}" />
|
||||||
|
<link rel="icon" href="{{ assets_prefix }}resources/logos/claude-howto-logo.svg" type="image/svg+xml" />
|
||||||
|
<link rel="stylesheet" href="{{ assets_prefix }}vendor/fonts/fonts.css" />
|
||||||
|
<link rel="stylesheet" href="{{ assets_prefix }}tailwind.css" />
|
||||||
|
<link rel="stylesheet" href="{{ assets_prefix }}site.css" />
|
||||||
|
<script>
|
||||||
|
// Apply the saved theme before paint to avoid flash of unstyled content.
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem('claude-howto-theme');
|
||||||
|
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
var theme = saved || (prefersDark ? 'dark' : 'light');
|
||||||
|
if (theme === 'dark') {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-slate-50 text-slate-900 antialiased dark:bg-slate-950 dark:text-slate-100">
|
||||||
|
|
||||||
|
<header class="sticky top-0 z-30 border-b border-slate-200 bg-white/85 backdrop-blur dark:border-slate-800 dark:bg-slate-950/85">
|
||||||
|
<div class="mx-auto flex max-w-7xl items-center gap-3 px-4 py-3 sm:px-6 lg:px-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="nav-toggle"
|
||||||
|
class="inline-flex items-center justify-center rounded-md border border-slate-200 p-2 text-slate-600 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 lg:hidden"
|
||||||
|
aria-label="Open navigation"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M3 5h14a1 1 0 110 2H3a1 1 0 110-2zm0 4h14a1 1 0 110 2H3a1 1 0 110-2zm0 4h14a1 1 0 110 2H3a1 1 0 110-2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<a href="{{ base_path }}index.html" class="flex items-center gap-2 font-semibold tracking-tight">
|
||||||
|
<img
|
||||||
|
src="{{ assets_prefix }}resources/logos/claude-howto-logo.svg"
|
||||||
|
alt=""
|
||||||
|
class="h-7 w-auto dark:hidden"
|
||||||
|
onerror="this.style.display='none'"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src="{{ assets_prefix }}resources/logos/claude-howto-logo-dark.svg"
|
||||||
|
alt=""
|
||||||
|
class="hidden h-7 w-auto dark:block"
|
||||||
|
onerror="this.style.display='none'"
|
||||||
|
/>
|
||||||
|
<span class="text-sm sm:text-base">{{ site_title }}</span>
|
||||||
|
</a>
|
||||||
|
<div class="ml-auto flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href="{{ github_source_url }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="hidden items-center gap-1.5 rounded-md border border-slate-200 px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 sm:inline-flex"
|
||||||
|
title="View this page's markdown source on GitHub"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 005.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2 .37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Edit on GitHub</span>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="theme-toggle"
|
||||||
|
class="inline-flex items-center justify-center rounded-md border border-slate-200 p-2 text-slate-600 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
<svg class="hidden h-5 w-5 dark:block" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4.95 2.05a1 1 0 010 1.41l-.71.71a1 1 0 11-1.41-1.41l.71-.71a1 1 0 011.41 0zM18 9a1 1 0 110 2h-1a1 1 0 110-2h1zm-2.05 4.95a1 1 0 011.41 1.41l-.71.71a1 1 0 11-1.41-1.41l.71-.71zM10 16a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zm-4.95-2.05a1 1 0 010 1.41l-.71.71a1 1 0 11-1.41-1.41l.71-.71a1 1 0 011.41 0zM3 9a1 1 0 110 2H2a1 1 0 110-2h1zm2.05-4.95a1 1 0 011.41-1.41l.71.71a1 1 0 11-1.41 1.41l-.71-.71zM10 6a4 4 0 100 8 4 4 0 000-8z"/>
|
||||||
|
</svg>
|
||||||
|
<svg class="h-5 w-5 dark:hidden" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="mx-auto flex max-w-7xl gap-6 px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
{# ---------- Sidebar navigation ---------- #}
|
||||||
|
<aside
|
||||||
|
id="sidebar"
|
||||||
|
class="fixed inset-y-0 left-0 z-40 w-72 -translate-x-full transform overflow-y-auto border-r border-slate-200 bg-white px-4 pb-10 pt-20 transition-transform duration-200 ease-out dark:border-slate-800 dark:bg-slate-950 lg:sticky lg:top-20 lg:z-0 lg:h-[calc(100vh-5rem)] lg:translate-x-0 lg:bg-transparent lg:px-0 lg:pt-0 lg:dark:bg-transparent"
|
||||||
|
>
|
||||||
|
<nav class="space-y-6 text-sm">
|
||||||
|
{% for section in nav %}
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
||||||
|
{{ section.name }}
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-0.5">
|
||||||
|
{% for item in section['items'] %}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="{{ item.url }}"
|
||||||
|
class="block rounded-md px-3 py-1.5 transition-colors {% if item.is_current %}bg-brand-50 font-semibold text-brand-700 dark:bg-brand-700/20 dark:text-brand-100{% else %}text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white{% endif %}"
|
||||||
|
>
|
||||||
|
{{ item.title }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{# Backdrop for mobile sidebar #}
|
||||||
|
<div
|
||||||
|
id="sidebar-backdrop"
|
||||||
|
class="fixed inset-0 z-30 hidden bg-slate-900/40 lg:hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{# ---------- Main content ---------- #}
|
||||||
|
<main class="flex min-w-0 flex-1 flex-col">
|
||||||
|
<nav class="mb-4 text-xs text-slate-500 dark:text-slate-400" aria-label="Breadcrumb">
|
||||||
|
<ol class="flex flex-wrap items-center gap-1.5">
|
||||||
|
<li><a href="{{ base_path }}index.html" class="hover:underline">Home</a></li>
|
||||||
|
<li aria-hidden="true">/</li>
|
||||||
|
<li class="text-slate-700 dark:text-slate-200">{{ section }}</li>
|
||||||
|
{% if page_title != section %}
|
||||||
|
<li aria-hidden="true">/</li>
|
||||||
|
<li class="text-slate-700 dark:text-slate-200">{{ page_title }}</li>
|
||||||
|
{% endif %}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<article class="prose prose-slate max-w-none prose-headings:scroll-mt-24 prose-headings:font-semibold prose-h1:text-3xl prose-h2:text-2xl prose-h3:text-xl prose-a:text-brand-600 prose-a:no-underline hover:prose-a:underline prose-code:rounded prose-code:bg-slate-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:font-mono prose-code:text-sm prose-code:before:content-none prose-code:after:content-none prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-img:rounded-lg prose-table:text-sm dark:prose-invert dark:prose-code:bg-slate-800 dark:prose-pre:bg-slate-900">
|
||||||
|
{{ content | safe }}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{% if prev_page or next_page %}
|
||||||
|
<div class="mt-12 grid grid-cols-1 gap-3 border-t border-slate-200 pt-6 dark:border-slate-800 sm:grid-cols-2">
|
||||||
|
{% if prev_page %}
|
||||||
|
<a
|
||||||
|
href="{{ prev_page.url }}"
|
||||||
|
class="group rounded-lg border border-slate-200 p-4 transition hover:border-brand-500 hover:bg-brand-50/50 dark:border-slate-800 dark:hover:border-brand-500 dark:hover:bg-brand-700/10"
|
||||||
|
>
|
||||||
|
<div class="text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400">← Previous</div>
|
||||||
|
<div class="mt-1 font-semibold text-slate-900 group-hover:text-brand-700 dark:text-slate-100 dark:group-hover:text-brand-300">
|
||||||
|
{{ prev_page.title }}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span></span>
|
||||||
|
{% endif %}
|
||||||
|
{% if next_page %}
|
||||||
|
<a
|
||||||
|
href="{{ next_page.url }}"
|
||||||
|
class="group rounded-lg border border-slate-200 p-4 text-right transition hover:border-brand-500 hover:bg-brand-50/50 dark:border-slate-800 dark:hover:border-brand-500 dark:hover:bg-brand-700/10"
|
||||||
|
>
|
||||||
|
<div class="text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400">Next →</div>
|
||||||
|
<div class="mt-1 font-semibold text-slate-900 group-hover:text-brand-700 dark:text-slate-100 dark:group-hover:text-brand-300">
|
||||||
|
{{ next_page.title }}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<footer class="mt-12 border-t border-slate-200 pt-6 text-xs text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||||
|
<p>
|
||||||
|
Content rendered from
|
||||||
|
<a href="{{ github_source_url }}" target="_blank" rel="noopener noreferrer" class="underline hover:text-brand-700 dark:hover:text-brand-300">
|
||||||
|
{{ page_title }}
|
||||||
|
</a>
|
||||||
|
on GitHub. Markdown is the single source of truth — re-run
|
||||||
|
<code class="rounded bg-slate-100 px-1.5 py-0.5 font-mono dark:bg-slate-800">scripts/build_website.py</code>
|
||||||
|
after editing to refresh the site.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{# ---------- In-page TOC ---------- #}
|
||||||
|
{% if toc %}
|
||||||
|
<aside class="hidden w-56 xl:block">
|
||||||
|
<div class="sticky top-24 text-sm">
|
||||||
|
<div class="mb-2 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
||||||
|
On this page
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{% for entry in toc %}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="#{{ entry.anchor }}"
|
||||||
|
class="block truncate text-slate-600 hover:text-brand-700 dark:text-slate-400 dark:hover:text-brand-300 {% if entry.level == 'h3' %}pl-3 text-xs{% endif %}"
|
||||||
|
>{{ entry.text }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{{ assets_prefix }}vendor/mermaid/mermaid.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
if (typeof mermaid === 'undefined') return;
|
||||||
|
var prefersDark = document.documentElement.classList.contains('dark');
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: true,
|
||||||
|
theme: prefersDark ? 'dark' : 'default',
|
||||||
|
securityLevel: 'strict',
|
||||||
|
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var toggle = document.getElementById('theme-toggle');
|
||||||
|
if (toggle) {
|
||||||
|
toggle.addEventListener('click', function () {
|
||||||
|
var isDark = document.documentElement.classList.toggle('dark');
|
||||||
|
try {
|
||||||
|
localStorage.setItem('claude-howto-theme', isDark ? 'dark' : 'light');
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var navToggle = document.getElementById('nav-toggle');
|
||||||
|
var sidebar = document.getElementById('sidebar');
|
||||||
|
var backdrop = document.getElementById('sidebar-backdrop');
|
||||||
|
function closeSidebar() {
|
||||||
|
if (!sidebar) return;
|
||||||
|
sidebar.classList.add('-translate-x-full');
|
||||||
|
if (backdrop) backdrop.classList.add('hidden');
|
||||||
|
}
|
||||||
|
function openSidebar() {
|
||||||
|
if (!sidebar) return;
|
||||||
|
sidebar.classList.remove('-translate-x-full');
|
||||||
|
if (backdrop) backdrop.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
if (navToggle && sidebar) {
|
||||||
|
navToggle.addEventListener('click', function () {
|
||||||
|
if (sidebar.classList.contains('-translate-x-full')) {
|
||||||
|
openSidebar();
|
||||||
|
} else {
|
||||||
|
closeSidebar();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (backdrop) backdrop.addEventListener('click', closeSidebar);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
104
scripts/website_templates/site.css
Normal file
104
scripts/website_templates/site.css
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/* Site-specific styles layered on top of Tailwind. */
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
pre,
|
||||||
|
kbd,
|
||||||
|
samp {
|
||||||
|
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mermaid diagrams: centre and constrain width so they read on mobile. */
|
||||||
|
.mermaid {
|
||||||
|
background: transparent !important;
|
||||||
|
padding: 1rem 0;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
text-align: center;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mermaid svg {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent prose pre/code from inheriting the Tailwind dark prose background
|
||||||
|
which can clash with our slate-900 preference. */
|
||||||
|
.prose pre {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
line-height: 1.55;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose pre code {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline code gets a softer treatment than fenced blocks. */
|
||||||
|
.prose :where(p, li, h1, h2, h3, h4) code {
|
||||||
|
font-size: 0.875em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables wrap on mobile rather than forcing horizontal scroll on every page. */
|
||||||
|
.prose table {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.prose table {
|
||||||
|
display: table;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Anchor offset so sticky header doesn't cover heading targets. */
|
||||||
|
.prose :where(h1, h2, h3, h4, h5, h6) {
|
||||||
|
scroll-margin-top: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide focus rings on mouse, keep for keyboard. */
|
||||||
|
:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pygments syntax highlighting (paired with `markdown` codehilite extension). */
|
||||||
|
.codehilite { background: transparent; }
|
||||||
|
.codehilite .hll { background-color: #ffffcc }
|
||||||
|
.codehilite .c, .codehilite .c1, .codehilite .cm { color: #999988; font-style: italic }
|
||||||
|
.codehilite .k, .codehilite .kc, .codehilite .kd, .codehilite .kn, .codehilite .kp,
|
||||||
|
.codehilite .kr, .codehilite .kt { color: #f0883e; font-weight: 600 }
|
||||||
|
.codehilite .s, .codehilite .s1, .codehilite .s2 { color: #a5d6ff }
|
||||||
|
.codehilite .nb { color: #79c0ff }
|
||||||
|
.codehilite .nf { color: #d2a8ff }
|
||||||
|
.codehilite .o, .codehilite .p { color: #ff7b72 }
|
||||||
|
.codehilite .m, .codehilite .mi, .codehilite .mf { color: #79c0ff }
|
||||||
|
.codehilite .nv, .codehilite .vc, .codehilite .vg, .codehilite .vi { color: #ffa657 }
|
||||||
|
.codehilite .err { color: inherit }
|
||||||
|
|
||||||
|
/* Light-theme readability tweaks for code colours. */
|
||||||
|
:root:not(.dark) .codehilite .k,
|
||||||
|
:root:not(.dark) .codehilite .kc,
|
||||||
|
:root:not(.dark) .codehilite .kd,
|
||||||
|
:root:not(.dark) .codehilite .kn,
|
||||||
|
:root:not(.dark) .codehilite .kp,
|
||||||
|
:root:not(.dark) .codehilite .kr,
|
||||||
|
:root:not(.dark) .codehilite .kt { color: #cf222e; }
|
||||||
|
:root:not(.dark) .codehilite .s,
|
||||||
|
:root:not(.dark) .codehilite .s1,
|
||||||
|
:root:not(.dark) .codehilite .s2 { color: #0a3069; }
|
||||||
|
:root:not(.dark) .codehilite .nb { color: #0550ae; }
|
||||||
|
:root:not(.dark) .codehilite .nf { color: #6f42c1; }
|
||||||
|
:root:not(.dark) .codehilite .m,
|
||||||
|
:root:not(.dark) .codehilite .mi,
|
||||||
|
:root:not(.dark) .codehilite .mf { color: #0550ae; }
|
||||||
35
scripts/website_templates/tailwind.config.js
Normal file
35
scripts/website_templates/tailwind.config.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
//
|
||||||
|
// Used by the Tailwind standalone CLI (invoked from scripts/vendor_assets.py).
|
||||||
|
// The `content` globs are passed as absolute paths via env vars so the build
|
||||||
|
// works from any cwd.
|
||||||
|
//
|
||||||
|
const path = require('path');
|
||||||
|
const siteDir = process.env.TAILWIND_SITE_DIR || path.join(__dirname, '..', '..', 'site');
|
||||||
|
const templateDir = process.env.TAILWIND_TEMPLATE_DIR || __dirname;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
path.join(siteDir, '**/*.html'),
|
||||||
|
path.join(templateDir, '**/*.j2'),
|
||||||
|
],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||||
|
mono: ['"JetBrains Mono"', 'ui-monospace', 'monospace'],
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: '#eef2ff',
|
||||||
|
100: '#e0e7ff',
|
||||||
|
500: '#6366f1',
|
||||||
|
600: '#4f46e5',
|
||||||
|
700: '#4338ca',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require('@tailwindcss/typography')],
|
||||||
|
};
|
||||||
3
scripts/website_templates/tailwind.input.css
Normal file
3
scripts/website_templates/tailwind.input.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
Reference in New Issue
Block a user