mirror of
https://github.com/luongnv89/claude-howto.git
synced 2026-07-07 11:17:48 +02:00
* 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:
@@ -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
|
||||
@@ -90,3 +90,8 @@ promo-video/
|
||||
*.mp4
|
||||
|
||||
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]
|
||||
files: ^scripts/
|
||||
exclude: ^scripts/tests/
|
||||
additional_dependencies:
|
||||
- types-Markdown
|
||||
|
||||
# Local doc quality hooks (mirrors CI checks — CI is a 2nd pass of these)
|
||||
- repo: local
|
||||
|
||||
@@ -3,6 +3,19 @@
|
||||
<img alt="Claude How To" src="../resources/logos/claude-howto-logo.svg">
|
||||
</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
|
||||
|
||||
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`.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -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:
|
||||
try:
|
||||
domain = url.split("/")[2]
|
||||
domain = url.split("/", 3)[2].split(":", 1)[0]
|
||||
except IndexError:
|
||||
return True # malformed URL
|
||||
if any(skip == domain or domain.endswith("." + skip) for skip in SKIP_DOMAINS):
|
||||
|
||||
@@ -12,6 +12,7 @@ dependencies = [
|
||||
"httpx",
|
||||
"pillow",
|
||||
"tenacity",
|
||||
"jinja2",
|
||||
]
|
||||
|
||||
[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
|
||||
markdown==3.7
|
||||
beautifulsoup4==4.12.3
|
||||
httpx==0.28.1
|
||||
pillow==11.1.0
|
||||
tenacity==9.0.0
|
||||
jinja2==3.1.4
|
||||
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
@@ -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')],
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
Reference in New Issue
Block a user