
Documentation Writing
- 297 installs
- 70 repo stars
- Updated July 26, 2026
- rysweet/amplihack
documentation-writing is a Claude Code skill that authors READMEs, API references, runbooks, and contributor guides for the amplihack project so operational docs stay aligned with code.
About
documentation-writing is a Claude Code skill focused on producing and maintaining amplihack project documentation. It guides agents to write clear README files, API references, operational runbooks, and contributor guides that capture setup steps, service contracts, and day-two procedures. The skill is meant to keep documentation accurate as repositories change, reducing onboarding friction and preventing drift between implemented behavior and published instructions. Developers reach for documentation-writing when amplihack modules need structured docs for new endpoints, environment setup, release steps, or contribution workflows instead of ad hoc markdown patches.
- Structures README and API reference sections
- Keeps setup and configuration steps current
- Documents endpoints, events, and error semantics
- Writes contributor and operations runbooks
- Aligns prose tone with amplihack conventions
Documentation Writing by the numbers
- 297 all-time installs (skills.sh)
- +1 installs in the week ending Jul 26, 2026 (Skillselion tracking)
- Ranked #452 of 1,879 Documentation skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rysweet/amplihack --skill documentation-writingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 297 |
|---|---|
| repo stars | ★ 70 |
| Last updated | July 26, 2026 |
| Repository | rysweet/amplihack ↗ |
How do you keep amplihack docs accurate as code changes?
Author clear READMEs, API references, runbooks, and contributor guides for amplihack so setup, contracts, and operational steps stay accurate as code changes.
Who is it for?
Developers maintaining the amplihack repository who need consistent README, API, runbook, and contributor documentation.
Skip if: Teams seeking generic blog posts or marketing copy unrelated to amplihack setup and operational docs.
When should I use this skill?
The user asks to document amplihack setup, APIs, runbooks, or contribution steps after code changes.
What you get
Updated README files, API reference pages, operational runbooks, and contributor guides aligned to amplihack code.
- README updates
- API reference pages
- runbooks
Files
Documentation Writing Skill
Purpose
Creates high-quality, discoverable documentation following the Eight Rules and Diataxis framework. Ensures all docs are properly located, linked, and contain real runnable examples.
When I Activate
I load automatically when you mention:
- "write documentation" or "create docs"
- "document this feature/module/API"
- "create a README" or "write a tutorial"
- "explain how this works"
- Any request to create markdown documentation
Core Rules (MANDATORY)
The Eight Rules
1. Location: All docs in docs/ directory 2. Linking: Every doc linked from at least one other doc 3. Simplicity: Plain language, remove unnecessary words 4. Real Examples: Runnable code, not "foo/bar" placeholders 5. Diataxis: One doc type per file (tutorial/howto/reference/explanation) 6. Scanability: Descriptive headings, table of contents for long docs 7. Local Links: Relative paths, context with links 8. Currency: Delete outdated docs, include update metadata
What Stays OUT of Docs
Never put in `docs/`:
- Status reports or progress updates
- Test results or benchmarks
- Meeting notes or decisions
- Plans with dates
- Point-in-time snapshots
Where temporal info belongs:
- Test results → CI logs, GitHub Actions
- Status updates → GitHub Issues
- Progress → Pull Request descriptions
- Decisions → Commit messages
Quick Start
Creating a New Document
# [Feature Name]
Brief one-sentence description of what this is.
## Quick Start
Minimal steps to get started (3-5 steps max).
## Contents
- [Configuration](#configuration)
- [Usage](#usage)
- [Troubleshooting](#troubleshooting)
## Configuration
Step-by-step setup with real examples.
## Usage
Common use cases with runnable code.
## Troubleshooting
Common problems and solutions.Document Types (Diataxis)
| Type | Purpose | Location | User Question |
|---|---|---|---|
| Tutorial | Learning | docs/tutorials/ | "Teach me how" |
| How-To | Doing | docs/howto/ | "Help me do X" |
| Reference | Information | docs/reference/ | "What are the options?" |
| Explanation | Understanding | docs/concepts/ | "Why is it this way?" |
Workflow
Step 1: Determine Document Type
Ask: What is the reader trying to accomplish?
- Learning something new → Tutorial
- Solving a specific problem → How-To
- Looking up details → Reference
- Understanding concepts → Explanation
Step 2: Choose Location
docs/
├── tutorials/ # Learning-oriented
├── howto/ # Task-oriented
├── reference/ # Information-oriented
├── concepts/ # Understanding-oriented
└── index.md # Links to all docsStep 3: Write with Examples
Every concept needs a runnable example:
# Example: Analyze file complexity
from amplihack import analyze
result = analyze("src/main.py")
print(f"Complexity: {result.score}")
# Output: Complexity: 12.5Step 4: Link from Index
Add entry to docs/index.md:
- [New Feature Guide](./howto/new-feature.md) - How to configure XStep 5: Validate
Checklist before completion:
- [ ] File in
docs/directory - [ ] Linked from index or parent doc
- [ ] No temporal information
- [ ] All examples tested
- [ ] Follows one Diataxis type
Navigation Guide
When to Read Supporting Files
reference.md - Read when you need:
- Complete frontmatter specification
- Detailed Diataxis type definitions
- Markdown style conventions
- Documentation review checklist
examples.md - Read when you need:
- Full document templates for each type
- Real-world documentation examples
- Before/after improvement examples
- Complex documentation patterns
Anti-Patterns to Avoid
| Anti-Pattern | Why It's Bad | Better Approach |
|---|---|---|
| "Click here" links | No context | "See auth config" |
| foo/bar examples | Not realistic | Use real project code |
| Wall of text | Hard to scan | Use headings and bullets |
| Orphan docs | Never found | Link from index |
| Status in docs | Gets stale | Use Issues/PRs |
Retcon Documentation Exception
When writing documentation BEFORE implementation (document-driven development):
````markdown
[PLANNED - Implementation Pending]
This document describes the intended behavior of Feature X.
Planned Interface
# [PLANNED] - This API will be implemented
def future_function(input: str) -> Result:
"""Process input and return result."""
pass````
Once implemented, remove the [PLANNED] markers and update with real examples.
---
**Full reference**: See [reference.md](./reference.md) for complete specification.
**Templates**: See [examples.md](./examples.md) for copy-paste templates.Documentation Writing - Examples and Templates
This file contains copy-paste ready templates and real-world examples for each documentation type.
Template: Tutorial
````markdown --- title: "Tutorial: [Topic]" doc_type: tutorial last_updated: YYYY-MM-DD ---
Tutorial: [What You'll Build]
Brief description of what the reader will accomplish.
What You'll Learn
- Skill 1
- Skill 2
- Skill 3
Prerequisites
- Requirement 1
- Requirement 2
Time Required
Approximately X minutes.
---
Step 1: [First Action]
Brief context (1-2 sentences max).
# Command to run
actual-command --with-args````
Expected result: Description of what should happen.
Step 2: [Second Action]
Context sentence.
# Complete, runnable code
def example():
result = real_function()
print(result)
# Run it
example()Checkpoint: You should see expected output.
Step 3: [Third Action]
Continue the pattern...
---
Summary
What you accomplished:
- Achievement 1
- Achievement 2
Next Steps
- Advanced Topic
- Related Tutorial
````
Template: How-To Guide
---
title: "How to [Accomplish Goal]"
doc_type: howto
last_updated: YYYY-MM-DD
---
# How to [Accomplish Goal]
One sentence describing what this guide helps you do.
## Prerequisites
- [ ] Prerequisite 1 completed
- [ ] Prerequisite 2 in place
## Steps
### 1. [Action Verb] [Thing]
command-to-run ````
2. [Action Verb] [Thing]
code_to_execute()3. [Action Verb] [Thing]
Final step with verification.
Variations
For [Variation A]
If your situation is X, do this instead:
alternative-commandFor [Variation B]
When Y applies, use:
another-alternativeTroubleshooting
[Common Problem 1]
Symptom: What the user sees.
Solution:
fix-command[Common Problem 2]
Symptom: Error message or behavior.
Solution: Explanation and fix.
See Also
- Related Reference
- Related How-To
````
Template: Reference
---
title: "[Feature/API] Reference"
doc_type: reference
last_updated: YYYY-MM-DD
---
# [Feature/API] Reference
## Overview
Brief factual description of what this is.
## Configuration
### Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `VAR_NAME` | Yes | - | What it controls |
| `OTHER_VAR` | No | `default` | What it does |
### Configuration File
Location: `path/to/config.yaml`
setting_name: option_a: value # Description option_b: value # Description ````
API
function_name(param1, param2)
Description of what this function does.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
param1 | string | Yes | What it's for |
param2 | int | No | What it controls |
Returns: ReturnType - Description
Raises:
ValueError: When invalid inputRuntimeError: When operation fails
Example:
result = function_name("input", 42)
print(result)
# Output: expected outputother_function()
Same structure...
Data Types
TypeName
@dataclass
class TypeName:
field1: str # Description
field2: int # Description
field3: bool # Description (default: True)Error Codes
| Code | Name | Meaning |
|---|---|---|
| 100 | SUCCESS | Operation completed |
| 400 | INVALID_INPUT | Bad parameters |
| 500 | INTERNAL_ERROR | System failure |
See Also
- Tutorial: Getting Started
- How-To: Common Tasks
````
Template: Explanation
---
title: "Understanding [Concept]"
doc_type: explanation
last_updated: YYYY-MM-DD
---
# Understanding [Concept]
## What Is [Concept]?
Clear definition in 2-3 sentences.
## Why It Matters
Explain the significance and context. Why should the reader care?
## How It Works
### [Component 1]
Explanation of first major part...
### [Component 2]
Explanation of second major part...
## The Trade-offs
| Approach | Advantages | Disadvantages |
|----------|------------|---------------|
| Option A | Pro 1, Pro 2 | Con 1 |
| Option B | Pro 1 | Con 1, Con 2 |
## Historical Context
How did this concept evolve? What problems was it designed to solve?
## Common Misconceptions
### Misconception 1
What people often think, and what's actually true.
### Misconception 2
Another common misunderstanding and the reality.
## Comparison with Alternatives
How does this approach compare to other ways of solving the same problem?
| Feature | [Concept] | Alternative 1 | Alternative 2 |
|---------|-----------|---------------|---------------|
| Feature A | Yes | No | Partial |
| Feature B | Yes | Yes | No |
## When to Use This
- Situation 1
- Situation 2
## When NOT to Use This
- Situation where alternatives are better
- Edge cases where it doesn't apply
## Related Concepts
- [Related Concept 1](./related-1.md)
- [Related Concept 2](./related-2.md)
## Further Reading
- [External Resource](https://example.com)Real Example: Before and After
Before (Poor Documentation)
# Auth
You need to set up authentication.
First get a token then use it.
Example:thing = do_auth(foo)
For more info see other docs.After (Good Documentation)
````markdown --- title: "How to Set Up Authentication" doc_type: howto last_updated: 2025-11-25 ---
How to Set Up Authentication
Configure JWT authentication for API access.
Prerequisites
- [ ] API key from developer portal
- [ ] Python 3.10+ installed
Steps
1. Configure Environment
export AUTH_API_KEY="your-api-key-here" # pragma: allowlist secret2. Initialize Authentication
from amplihack.auth import Authenticator
auth = Authenticator()
token = auth.get_token()
print(f"Token: {token[:20]}...")
# Output: Token: eyJhbGciOiJIUzI1N...3. Use Token in Requests
import requests
response = requests.get(
"https://api.example.com/data",
headers={"Authorization": f"Bearer {token}"}
)
print(response.status_code)
# Output: 200Troubleshooting
Token expired
Symptom: 401 Unauthorized errors
Solution: Tokens expire after 1 hour. Call auth.refresh_token().
See Also
- Authentication Reference
- API Usage Guide
````
Example: Linking Documentation
In docs/index.md
# amplihack Documentation
## Getting Started
- [Installation](./tutorials/installation.md)
- [First Agent](./tutorials/first-agent.md)
## How-To Guides
- [Authentication Setup](./howto/authentication.md)
- [Deploy to Azure](./howto/azure-deploy.md)
## Reference
- [API Reference](./reference/api.md)
- [Configuration](./reference/config.md)
## Concepts
- [Architecture Overview](./concepts/architecture.md)
- [The Brick Philosophy](./concepts/brick-philosophy.md)Cross-Linking Between Docs
# Tutorial: First Agent
...tutorial content...
## Next Steps
Now that you've built your first agent:
1. Learn about [authentication](../howto/authentication.md) to secure your agent
2. Read the [API reference](../reference/api.md) for all available methods
3. Understand [the brick philosophy](../concepts/brick-philosophy.md) for best practicesExample: Avoiding Temporal Information
Bad (Don't Do This)
# Feature Update - November 2025
We're excited to announce that as of last week, we've completed
80% of the new authentication system. The team is working hard
and we expect to finish by end of month.
Current status:
- Login: Done
- Token refresh: In progress
- Logout: Not startedGood (Do This Instead)
The temporal information goes in a GitHub Issue or PR:
In GitHub Issue #123:
## Authentication System Implementation
### Status: In Progress
- [x] Login endpoint
- [ ] Token refresh
- [ ] Logout endpoint
**Target**: v2.0 releaseIn `docs/reference/auth.md`:
# Authentication Reference
This document describes the authentication system.
## Available Endpoints
| Endpoint | Status | Description |
| ---------- | ------- | ------------------- |
| `/login` | Stable | User authentication |
| `/refresh` | Beta | Token refresh |
| `/logout` | Planned | Session termination |
> **Note**: Beta features may change. See [release notes](../releases.md)."""GitHub Pages Documentation Site Generation.
A complete solution for generating, validating, and deploying documentation
sites to GitHub Pages using MkDocs with the Material theme.
Philosophy:
- Single responsibility: Generate docs -> Validate docs -> Deploy docs
- Standard library when possible, external deps only where necessary (mkdocs, pyyaml)
- Self-contained and regeneratable
- Zero-BS: No stubs, no placeholders - everything works
Public API (the "studs"):
SiteConfig: Configuration for site generation
DeploymentConfig: Configuration for deployment
GenerationResult: Result of site generation
ValidationResult: Result of three-pass validation
ValidationIssue: Single validation issue
DeploymentResult: Result of deployment
generate_site: Generate documentation site from docs/
validate_site: Run three-pass validation
deploy_site: Deploy to GitHub Pages
preview_locally: Start local preview server
"""
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
@dataclass
class SiteConfig:
"""Configuration for site generation.
Attributes:
project_name: Name of the project (used in site title)
project_url: GitHub repository URL
docs_dir: Path to documentation directory (default: "docs")
output_dir: Path for generated site output (default: "site")
theme: MkDocs theme to use (default: "material")
theme_features: List of Material theme features to enable
nav_structure: Custom navigation structure (auto-generated if None)
"""
project_name: str
project_url: str
docs_dir: str | Path = "docs"
output_dir: str | Path = "site"
theme: str = "material"
theme_features: list[str] | None = None
nav_structure: dict | None = None
@dataclass
class DeploymentConfig:
"""Configuration for deployment.
Attributes:
site_dir: Path to generated site directory
repo_path: Path to git repository root (default: ".")
commit_message: Commit message for deployment (default: "Update docs")
force_push: Whether to force push (DANGEROUS - default: False)
"""
site_dir: str | Path
repo_path: str | Path = "."
commit_message: str = "Update docs"
force_push: bool = False
@dataclass
class GenerationResult:
"""Result of site generation.
Attributes:
success: Whether generation succeeded
site_dir: Path to generated site directory
pages: List of generated page paths
errors: List of error messages
warnings: List of warning messages
config_file: Path to generated mkdocs.yml
"""
success: bool
site_dir: Path
pages: list[str]
errors: list[str]
warnings: list[str]
config_file: Path | None
@dataclass
class ValidationIssue:
"""Single validation issue.
Attributes:
severity: Issue severity level ("error", "warning", "info")
pass_number: Which validation pass found this (1, 2, or 3)
location: File path and optionally line number
message: Description of the issue
suggestion: Optional suggestion for fixing the issue
"""
severity: Literal["error", "warning", "info"]
pass_number: int
location: str
message: str
suggestion: str | None = None
@dataclass
class ValidationResult:
"""Result of three-pass validation.
Attributes:
passed: Whether validation passed all thresholds
issues: List of all validation issues found
pass1_coverage: Coverage percentage (target: 100%)
pass2_clarity_score: Clarity score (target: >= 80%)
pass3_grounded_pct: Percentage of grounded content (target: >= 95%)
"""
passed: bool
issues: list[ValidationIssue]
pass1_coverage: float
pass2_clarity_score: float
pass3_grounded_pct: float
@dataclass
class DeploymentResult:
"""Result of deployment.
Attributes:
success: Whether deployment succeeded
branch: Branch deployed to (usually "gh-pages")
commit_sha: SHA of the deployment commit (None if failed)
url: GitHub Pages URL (None if failed)
errors: List of error messages
"""
success: bool
branch: str
commit_sha: str | None
url: str | None
errors: list[str]
# Import implementations after dataclasses are defined
from .deployer import deploy_site
from .generator import generate_site, preview_locally
from .validator import validate_site
__all__ = [
# Configuration classes
"SiteConfig",
"DeploymentConfig",
# Result classes
"GenerationResult",
"ValidationResult",
"ValidationIssue",
"DeploymentResult",
# Main functions
"generate_site",
"validate_site",
"deploy_site",
"preview_locally",
]
"""GitHub Pages deployer for documentation sites.
Deploys generated documentation sites to GitHub Pages via gh-pages branch.
Philosophy:
- Single responsibility: Deploy site to GitHub Pages
- Safe by default: Never force push unless explicitly requested
- Proper git workflow with rollback on failure
- Clear error messages for common issues
"""
import shutil
import subprocess
import tempfile
from pathlib import Path
from . import DeploymentConfig, DeploymentResult
def deploy_site(config: DeploymentConfig) -> DeploymentResult:
"""Deploy documentation site to GitHub Pages.
Args:
config: Deployment configuration
Returns:
DeploymentResult with deployment status
Raises:
TypeError: If config is None
ValueError: If site_dir doesn't exist or is empty
PermissionError: If unable to copy files
"""
if config is None:
raise TypeError("Config cannot be None")
site_path = Path(config.site_dir)
repo_path = Path(config.repo_path)
errors: list[str] = []
# Validate site directory exists and has content
if not site_path.exists():
raise ValueError(f"Site directory not found: {config.site_dir}")
site_contents = list(site_path.iterdir())
if not site_contents:
raise ValueError(f"Site directory is empty: {config.site_dir}")
# Check git status (should be clean or we might lose changes)
try:
is_clean = _check_git_status(repo_path)
if not is_clean:
# Allow deployment with uncommitted changes, but warn
pass
except Exception as e:
errors.append(f"Git status check failed: {e}")
return DeploymentResult(
success=False,
branch="gh-pages",
commit_sha=None,
url=None,
errors=errors,
)
# Get current branch to return to after deployment
try:
original_branch = _get_current_branch(repo_path)
except Exception:
original_branch = "main"
# Get repository URL for constructing Pages URL
try:
repo_url = _get_repo_url(repo_path)
pages_url = _construct_pages_url(repo_url)
except Exception:
repo_url = ""
pages_url = None
# Create a temporary directory for the deployment
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
# Copy site contents to temp directory
try:
for item in site_path.iterdir():
if item.is_dir():
shutil.copytree(item, tmp_path / item.name)
else:
shutil.copy2(item, tmp_path / item.name)
except PermissionError:
raise
# Validate branch name before operations
_validate_branch_name("gh-pages")
# Check if gh-pages branch exists
branch_exists = _branch_exists(repo_path, "gh-pages")
try:
if branch_exists:
# Switch to existing gh-pages branch
_run_git_command(repo_path, ["checkout", "gh-pages"])
else:
# Create orphan gh-pages branch
_run_git_command(repo_path, ["checkout", "--orphan", "gh-pages"])
# Remove all files from the new branch
_run_git_command(repo_path, ["rm", "-rf", "."], check=False)
# Clear the directory (except .git)
for item in repo_path.iterdir():
if item.name != ".git":
if item.is_dir():
shutil.rmtree(item)
else:
item.unlink()
# Copy site contents to repo root
for item in tmp_path.iterdir():
dest = repo_path / item.name
if item.is_dir():
shutil.copytree(item, dest)
else:
shutil.copy2(item, dest)
# Add .nojekyll file to disable Jekyll processing
(repo_path / ".nojekyll").touch()
# Stage all changes
_run_git_command(repo_path, ["add", "."])
# Check if there are changes to commit
status_result = _run_git_command(
repo_path, ["status", "--porcelain"], capture_output=True
)
if not status_result.stdout.strip():
# No changes to commit
_switch_branch(repo_path, original_branch)
return DeploymentResult(
success=True,
branch="gh-pages",
commit_sha=None,
url=pages_url,
errors=["No changes to deploy"],
)
# Commit changes
_run_git_command(repo_path, ["commit", "-m", config.commit_message])
# Get commit SHA
sha_result = _run_git_command(repo_path, ["rev-parse", "HEAD"], capture_output=True)
commit_sha = sha_result.stdout.strip()
# Push to remote
push_args = ["push", "origin", "gh-pages"]
if config.force_push:
push_args.insert(1, "--force")
try:
_run_git_command(repo_path, push_args)
except subprocess.CalledProcessError as e:
# CalledProcessError always has stderr when capture_output=True
errors.append(f"push failed: {e.stderr if e.stderr else str(e)}")
# Rollback to original branch
_switch_branch(repo_path, original_branch)
return DeploymentResult(
success=False,
branch="gh-pages",
commit_sha=commit_sha,
url=None,
errors=errors,
)
# Switch back to original branch
_switch_branch(repo_path, original_branch)
return DeploymentResult(
success=True,
branch="gh-pages",
commit_sha=commit_sha,
url=pages_url,
errors=[],
)
except subprocess.CalledProcessError as e:
# CalledProcessError always has stderr when capture_output=True
error_msg = e.stderr if e.stderr else str(e)
errors.append(f"Git operation failed: {error_msg}")
# Try to rollback to original branch
try:
_switch_branch(repo_path, original_branch)
except Exception:
pass
return DeploymentResult(
success=False,
branch="gh-pages",
commit_sha=None,
url=None,
errors=errors,
)
except OSError as e:
errors.append(f"File operation failed: {e!s}")
return DeploymentResult(
success=False,
branch="gh-pages",
commit_sha=None,
url=None,
errors=errors,
)
# ==============================================================================
# Git Helper Functions
# ==============================================================================
def _validate_branch_name(branch_name: str) -> None:
"""Validate git branch name for security.
Args:
branch_name: Branch name to validate
Raises:
ValueError: If branch name is invalid or potentially dangerous
"""
if not branch_name:
raise ValueError("Branch name cannot be empty")
# Check for dangerous characters
dangerous_chars = [";", "&", "|", "`", "$", "(", ")", "<", ">", "\n", "\r"]
for char in dangerous_chars:
if char in branch_name:
raise ValueError(f"Branch name contains invalid character: {char}")
# Check for path traversal attempts
if ".." in branch_name or branch_name.startswith("/"):
raise ValueError(f"Branch name contains invalid path components: {branch_name}")
# Validate against git branch naming rules
# Cannot start/end with slash, contain consecutive slashes, or end with .lock
if (
branch_name.startswith("/")
or branch_name.endswith("/")
or "//" in branch_name
or branch_name.endswith(".lock")
):
raise ValueError(f"Branch name violates git naming rules: {branch_name}")
def _validate_github_url(url: str) -> None:
"""Validate GitHub repository URL for security.
Args:
url: GitHub URL to validate
Raises:
ValueError: If URL is invalid or potentially dangerous
"""
if not url:
raise ValueError("GitHub URL cannot be empty")
# Check for dangerous characters that could be used for injection
dangerous_chars = [";", "&", "|", "`", "$", "(", ")", "<", ">", "\n", "\r", " "]
for char in dangerous_chars:
if char in url:
raise ValueError(f"GitHub URL contains invalid character: {char}")
# Validate URL format (must be GitHub)
valid_prefixes = [
"https://github.com/",
"git@github.com:",
"http://github.com/", # Will be upgraded to HTTPS
]
if not any(url.startswith(prefix) for prefix in valid_prefixes):
raise ValueError(
f"URL must be a valid GitHub URL (https://github.com/... or git@github.com:...): {url}"
)
def _run_git_command(
repo_path: Path,
args: list[str],
capture_output: bool = False,
check: bool = True,
) -> subprocess.CompletedProcess:
"""Run a git command in the repository.
Args:
repo_path: Path to repository
args: Git command arguments
capture_output: Whether to capture stdout/stderr
check: Whether to raise on non-zero exit
Returns:
CompletedProcess result
"""
cmd = ["git"] + args
result = subprocess.run(
cmd,
cwd=str(repo_path),
capture_output=capture_output,
text=True,
check=check,
)
return result
def _check_git_status(repo_path: Path) -> bool:
"""Check if git working directory is clean.
Args:
repo_path: Path to repository
Returns:
True if clean, False if dirty
"""
result = _run_git_command(
repo_path,
["status", "--porcelain"],
capture_output=True,
)
# Empty output means clean
return len(result.stdout.strip()) == 0
def _get_current_branch(repo_path: Path) -> str:
"""Get current git branch name.
Args:
repo_path: Path to repository
Returns:
Current branch name
"""
result = _run_git_command(
repo_path,
["rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
)
return result.stdout.strip()
def _get_repo_url(repo_path: Path) -> str:
"""Get repository URL from git remote.
Args:
repo_path: Path to repository
Returns:
Repository URL
"""
result = _run_git_command(
repo_path,
["remote", "get-url", "origin"],
capture_output=True,
)
return result.stdout.strip()
def _branch_exists(repo_path: Path, branch_name: str) -> bool:
"""Check if a branch exists.
Args:
repo_path: Path to repository
branch_name: Name of branch to check
Returns:
True if branch exists, False otherwise
"""
try:
result = _run_git_command(
repo_path,
["show-ref", "--verify", f"refs/heads/{branch_name}"],
capture_output=True,
check=False,
)
return result.returncode == 0
except Exception:
return False
def _switch_branch(repo_path: Path, branch_name: str) -> None:
"""Switch to a different branch.
Args:
repo_path: Path to repository
branch_name: Branch to switch to
Raises:
ValueError: If branch name is invalid
"""
_validate_branch_name(branch_name)
_run_git_command(repo_path, ["checkout", branch_name])
def _construct_pages_url(repo_url: str) -> str:
"""Construct GitHub Pages URL from repository URL.
Args:
repo_url: Repository URL (SSH or HTTPS)
Returns:
GitHub Pages URL
Raises:
ValueError: If URL is invalid
Examples:
>>> _construct_pages_url("git@github.com:user/repo.git")
'https://user.github.io/repo/'
>>> _construct_pages_url("https://github.com/user/repo.git")
'https://user.github.io/repo/'
"""
_validate_github_url(repo_url)
url = repo_url.rstrip("/")
if url.endswith(".git"):
url = url[:-4]
# Handle SSH format: git@github.com:user/repo
if "git@github.com:" in url:
parts = url.split("git@github.com:")[-1].split("/")
# Handle HTTPS format: https://github.com/user/repo
elif "github.com/" in url:
parts = url.split("github.com/")[-1].split("/")
else:
# Unknown format, try to extract last two parts
parts = url.split("/")[-2:]
owner = parts[0] if len(parts) > 0 else "unknown"
repo = parts[1] if len(parts) > 1 else "unknown"
return f"https://{owner}.github.io/{repo}/"
"""Site generator for GitHub Pages documentation.
Generates MkDocs documentation sites with Material theme from docs/ directory,
README.md, and command help text.
Philosophy:
- Single responsibility: Generate documentation site
- Discover content from multiple sources (docs/, README, commands)
- Build with MkDocs
- Graceful error handling with clear messages
"""
import subprocess
from pathlib import Path
from . import GenerationResult, SiteConfig
from .mkdocs_config import (
build_mkdocs_config,
write_mkdocs_yaml,
)
def generate_site(config: SiteConfig) -> GenerationResult:
"""Generate documentation site using MkDocs.
Args:
config: Site configuration
Returns:
GenerationResult with success status and details
Raises:
FileNotFoundError: If docs_dir doesn't exist
PermissionError: If unable to write to output directory
subprocess.CalledProcessError: If mkdocs build fails
"""
if config is None:
raise TypeError("Config cannot be None")
docs_path = Path(config.docs_dir)
output_path = Path(config.output_dir)
project_root = docs_path.parent
# Verify docs directory exists
if not docs_path.exists():
raise FileNotFoundError(f"Documentation directory not found: {config.docs_dir}")
errors: list[str] = []
warnings: list[str] = []
# Discover content
content_files = discover_content(docs_path)
if not content_files:
warnings.append("No markdown files found in docs directory")
# Check for README to potentially include
readme = discover_readme(project_root)
if readme:
# If no index.md exists, create one from README
index_path = docs_path / "index.md"
if not index_path.exists():
try:
readme_content = readme.read_text()
index_path.write_text(readme_content)
content_files.insert(0, index_path)
except Exception as e:
warnings.append(f"Could not copy README to index.md: {e}")
else:
if not (docs_path / "index.md").exists():
warnings.append("No README.md or index.md found")
# Discover commands for reference documentation
commands = discover_commands()
if commands:
_generate_command_reference(docs_path, commands)
# Refresh content list
content_files = discover_content(docs_path)
# Build MkDocs configuration
mkdocs_config = build_mkdocs_config(
project_name=config.project_name,
project_url=config.project_url,
docs_dir=str(docs_path),
theme_features=config.theme_features,
nav_structure=config.nav_structure,
)
# Write mkdocs.yml to project root
config_path = project_root / "mkdocs.yml"
write_mkdocs_yaml(mkdocs_config, config_path)
# Create output directory
output_path.mkdir(parents=True, exist_ok=True)
# Build site with MkDocs
try:
result = subprocess.run(
["mkdocs", "build", "--site-dir", str(output_path)],
cwd=str(project_root),
capture_output=True,
text=True,
timeout=120,
)
if result.returncode != 0:
errors.append(f"MkDocs build failed: {result.stderr}")
return GenerationResult(
success=False,
site_dir=output_path,
pages=[],
errors=errors,
warnings=warnings,
config_file=config_path,
)
# Collect generated pages
pages = _collect_generated_pages(output_path)
return GenerationResult(
success=True,
site_dir=output_path,
pages=pages,
errors=errors,
warnings=warnings,
config_file=config_path,
)
except FileNotFoundError:
raise FileNotFoundError(
"MkDocs not found. Install with: pip install mkdocs mkdocs-material"
)
except subprocess.TimeoutExpired:
errors.append("MkDocs build timed out after 120 seconds")
return GenerationResult(
success=False,
site_dir=output_path,
pages=[],
errors=errors,
warnings=warnings,
config_file=config_path,
)
def preview_locally(config_path: Path | str = "mkdocs.yml", port: int = 8000) -> None:
"""Start local preview server for documentation site.
Args:
config_path: Path to mkdocs.yml configuration
port: Port to serve on (default: 8000)
Note:
This function blocks until the server is stopped (Ctrl+C).
"""
config_path = Path(config_path)
project_root = config_path.parent
subprocess.run(
["mkdocs", "serve", "--dev-addr", f"127.0.0.1:{port}"],
cwd=str(project_root),
)
def discover_content(docs_dir: Path) -> list[Path]:
"""Discover markdown content in documentation directory.
Args:
docs_dir: Path to docs directory
Returns:
List of markdown file paths (sorted)
"""
if not docs_dir.exists():
return []
# Find all markdown files
md_files = list(docs_dir.rglob("*.md"))
# Filter to only markdown files (exclude other file types)
md_files = [f for f in md_files if f.suffix.lower() == ".md"]
# Sort with index.md first, then alphabetically
def sort_key(path: Path) -> tuple[int, str]:
if path.name.lower() == "index.md":
return (0, str(path))
return (1, str(path))
return sorted(md_files, key=sort_key)
def discover_readme(project_root: Path) -> Path | None:
"""Discover README.md in project root.
Args:
project_root: Path to project root directory
Returns:
Path to README.md if it exists, None otherwise
"""
readme_names = ["README.md", "readme.md", "Readme.md", "README.MD"]
for name in readme_names:
readme_path = project_root / name
if readme_path.exists():
return readme_path
return None
def discover_commands() -> dict[str, str]:
"""Discover command help text from CLI.
Returns:
Dictionary mapping command names to help text
"""
commands: dict[str, str] = {}
# Try to discover amplihack commands
try:
result = subprocess.run(
["amplihack", "--help"],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
commands["amplihack"] = result.stdout
except (FileNotFoundError, subprocess.TimeoutExpired):
pass # No amplihack CLI available
return commands
def _generate_command_reference(docs_dir: Path, commands: dict[str, str]) -> None:
"""Generate command reference documentation.
Args:
docs_dir: Path to docs directory
commands: Dictionary of command names to help text
"""
if not commands:
return
reference_dir = docs_dir / "reference"
reference_dir.mkdir(exist_ok=True)
cli_doc = reference_dir / "cli.md"
content = "# CLI Reference\n\n"
content += "Command-line interface reference documentation.\n\n"
for cmd_name, help_text in commands.items():
content += f"## {cmd_name}\n\n"
content += "```\n"
content += help_text
content += "\n```\n\n"
cli_doc.write_text(content)
def _collect_generated_pages(site_dir: Path) -> list[str]:
"""Collect list of generated HTML pages.
Args:
site_dir: Path to generated site directory
Returns:
List of relative paths to HTML pages
"""
if not site_dir.exists():
return []
pages = []
for html_file in site_dir.rglob("*.html"):
rel_path = html_file.relative_to(site_dir)
pages.append(str(rel_path))
return sorted(pages)
"""MkDocs configuration builder for GitHub Pages.
Generates mkdocs.yml configuration with Material theme for GitHub Pages deployment.
Philosophy:
- Single responsibility: Build and write MkDocs configuration
- Standard YAML output compatible with MkDocs
- Material theme with sensible defaults
- Auto-generate navigation from docs/ structure
"""
from pathlib import Path
from typing import Any
def _validate_github_url(url: str) -> None:
"""Validate GitHub repository URL for security.
Args:
url: GitHub URL to validate
Raises:
ValueError: If URL is invalid or potentially dangerous
"""
if not url:
raise ValueError("GitHub URL cannot be empty")
# Check for dangerous characters that could be used for injection
dangerous_chars = [";", "&", "|", "`", "$", "(", ")", "<", ">", "\n", "\r", " "]
for char in dangerous_chars:
if char in url:
raise ValueError(f"GitHub URL contains invalid character: {char}")
# Validate URL format (must be GitHub)
valid_prefixes = [
"https://github.com/",
"git@github.com:",
"http://github.com/", # Will be upgraded to HTTPS
]
if not any(url.startswith(prefix) for prefix in valid_prefixes):
raise ValueError(
f"URL must be a valid GitHub URL (https://github.com/... or git@github.com:...): {url}"
)
def build_mkdocs_config(
project_name: str,
project_url: str,
docs_dir: str | Path = "docs",
theme_features: list[str] | None = None,
nav_structure: dict | list | None = None,
) -> dict[str, Any]:
"""Build complete MkDocs configuration dictionary.
Args:
project_name: Name of the project (used in site title)
project_url: GitHub repository URL
docs_dir: Path to documentation directory
theme_features: List of Material theme features to enable
nav_structure: Custom navigation (auto-generated if None)
Returns:
Dictionary ready to be written as mkdocs.yml
Raises:
ValueError: If project_url is invalid
"""
_validate_github_url(project_url)
docs_path = Path(docs_dir)
# Build site URL from repo URL
site_url = _construct_site_url(project_url)
# Build theme configuration
theme_config = build_material_theme_config(features=theme_features)
# Build navigation
if nav_structure is not None:
nav = nav_structure
else:
# Auto-generate navigation from docs structure
if docs_path.exists():
md_files = list(docs_path.rglob("*.md"))
nav = generate_nav_structure(md_files)
else:
nav = [{"Home": "index.md"}]
config = {
"site_name": project_name,
"site_url": site_url,
"repo_url": project_url,
"repo_name": _extract_repo_name(project_url),
"edit_uri": "edit/main/docs/",
"theme": theme_config,
"plugins": ["search"],
"nav": nav,
"markdown_extensions": [
"pymdownx.highlight",
"pymdownx.superfences",
"pymdownx.tabbed",
"admonition",
"toc",
],
}
return config
def build_material_theme_config(
features: list[str] | None = None,
) -> dict[str, Any]:
"""Build Material theme configuration.
Args:
features: List of Material theme features to enable
Returns:
Theme configuration dictionary
"""
default_features = [
"navigation.tabs",
"navigation.sections",
"navigation.expand",
"search.highlight",
"search.suggest",
"content.code.copy",
]
theme_config = {
"name": "material",
"features": features if features is not None else default_features,
"palette": {
"primary": "indigo",
"accent": "indigo",
},
"icon": {
"repo": "fontawesome/brands/github",
},
}
return theme_config
def generate_nav_structure(files: list[Path]) -> list[dict[str, Any]]:
"""Generate navigation structure from documentation files.
Args:
files: List of markdown file paths
Returns:
Navigation structure as list of dicts for mkdocs.yml
"""
if not files:
return [{"Home": "index.md"}]
# Group files by directory
sections: dict[str, list[tuple[str, str]]] = {}
root_files: list[tuple[str, str]] = []
for file_path in files:
# Get relative path from docs directory
parts = file_path.parts
# Find 'docs' in path and get relative path after it
try:
docs_idx = parts.index("docs")
rel_parts = parts[docs_idx + 1 :]
except ValueError:
rel_parts = parts
if len(rel_parts) == 1:
# Root level file
filename = rel_parts[0]
name = _format_page_name(filename)
root_files.append((name, filename))
else:
# File in subdirectory
section = rel_parts[0]
section_name = _format_section_name(section)
filename = str(Path(*rel_parts))
if section_name not in sections:
sections[section_name] = []
page_name = _format_page_name(rel_parts[-1])
sections[section_name].append((page_name, filename))
# Build navigation list
nav: list[dict[str, Any]] = []
# Add Home first if index.md exists
home_files = [(name, path) for name, path in root_files if "index" in path.lower()]
if home_files:
nav.append({"Home": home_files[0][1]})
root_files = [(n, p) for n, p in root_files if "index" not in p.lower()]
# Add remaining root files
for name, path in root_files:
nav.append({name: path})
# Add sections in Diataxis order if they exist
diataxis_order = ["Tutorials", "How-To", "Reference", "Concepts"]
added_sections = set()
for section_name in diataxis_order:
if section_name in sections:
section_items = [{name: path} for name, path in sections[section_name]]
nav.append({section_name: section_items})
added_sections.add(section_name)
# Add remaining sections alphabetically
for section_name in sorted(sections.keys()):
if section_name not in added_sections:
section_items = [{name: path} for name, path in sections[section_name]]
nav.append({section_name: section_items})
return nav if nav else [{"Home": "index.md"}]
def write_mkdocs_yaml(config: dict[str, Any], output_path: Path) -> Path:
"""Write MkDocs configuration to YAML file.
Args:
config: Configuration dictionary
output_path: Path to write mkdocs.yml
Returns:
Path to written file
"""
import yaml
yaml_content = yaml.dump(
config,
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
indent=2,
)
output_path.write_text(yaml_content)
return output_path
def validate_config(config: dict[str, Any]) -> None:
"""Validate MkDocs configuration.
Args:
config: Configuration dictionary to validate
Raises:
ValueError: If configuration is invalid
"""
if "site_name" not in config:
raise ValueError("Configuration missing required field: site_name")
if "theme" not in config:
raise ValueError("Configuration missing required field: theme")
if config["theme"].get("name") != "material":
raise ValueError("Theme must be 'material' for GitHub Pages generation")
def _format_section_name(section: str) -> str:
"""Format directory name as section title.
Args:
section: Directory name (e.g., "api-reference", "howto")
Returns:
Formatted section name (e.g., "API Reference", "How-To")
"""
# Handle special cases
special_cases = {
"api": "API",
"api-reference": "API Reference",
"howto": "How-To",
"how-to": "How-To",
"cli": "CLI",
}
lower_section = section.lower()
if lower_section in special_cases:
return special_cases[lower_section]
# General formatting: replace dashes/underscores, title case
formatted = section.replace("-", " ").replace("_", " ")
return formatted.title()
def _format_page_name(filename: str) -> str:
"""Format filename as page title.
Args:
filename: Markdown filename (e.g., "getting-started.md")
Returns:
Formatted page name (e.g., "Getting Started")
"""
# Remove .md extension
name = filename.replace(".md", "")
# Handle special cases
if name.lower() == "index":
return "Home"
# General formatting
formatted = name.replace("-", " ").replace("_", " ")
return formatted.title()
def _extract_repo_info(repo_url: str) -> tuple[str, str]:
"""Extract owner and repository name from GitHub URL.
Args:
repo_url: GitHub repository URL
Returns:
Tuple of (owner, repo_name)
Raises:
ValueError: If URL is invalid
"""
_validate_github_url(repo_url)
# Handle both HTTPS and SSH formats
# https://github.com/owner/repo
# git@github.com:owner/repo.git
url = repo_url.rstrip("/")
if url.endswith(".git"):
url = url[:-4]
if "github.com/" in url:
parts = url.split("github.com/")[-1].split("/")
elif "github.com:" in url:
parts = url.split("github.com:")[-1].split("/")
else:
parts = url.split("/")[-2:]
owner = parts[0] if len(parts) > 0 else "unknown"
repo = parts[1] if len(parts) > 1 else "unknown"
return owner, repo
def _extract_repo_name(repo_url: str) -> str:
"""Extract repository name from URL for display.
Args:
repo_url: GitHub repository URL
Returns:
Repository name in owner/repo format
"""
owner, repo = _extract_repo_info(repo_url)
return f"{owner}/{repo}"
def _construct_site_url(repo_url: str) -> str:
"""Construct GitHub Pages URL from repository URL.
Args:
repo_url: GitHub repository URL
Returns:
GitHub Pages URL
"""
owner, repo = _extract_repo_info(repo_url)
return f"https://{owner}.github.io/{repo}/"
GitHub Pages Documentation Generation
A complete solution for generating, validating, and deploying documentation sites to GitHub Pages using MkDocs with the Material theme.
Philosophy
- Single responsibility: Each module handles one concern (generate, validate, deploy)
- Standard library when possible: External deps only where necessary (mkdocs, pyyaml)
- Self-contained and regeneratable: Module can be rebuilt from this specification
- Zero-BS: No stubs, no placeholders - everything works
Module Structure
github_pages/
├── __init__.py # Public API (dataclasses + function exports)
├── generator.py # Site generation with MkDocs
├── validator.py # Three-pass documentation validation
├── deployer.py # GitHub Pages deployment via gh-pages branch
├── mkdocs_config.py # MkDocs configuration builder
├── tests/ # Comprehensive test suite
│ ├── conftest.py # Shared fixtures
│ ├── test_generator.py
│ ├── test_validator.py
│ ├── test_deployer.py
│ ├── test_mkdocs_config.py
│ └── test_integration.py
└── README.md # This filePublic API
Configuration Dataclasses
from github_pages import SiteConfig, DeploymentConfig
# Site generation configuration
site_config = SiteConfig(
project_name="My Project",
project_url="https://github.com/user/repo",
docs_dir="docs", # Default
output_dir="site", # Default
theme="material", # Default
theme_features=None, # Optional custom features
nav_structure=None, # Optional custom navigation
)
# Deployment configuration
deploy_config = DeploymentConfig(
site_dir="site",
repo_path=".", # Default
commit_message="Update docs", # Default
force_push=False, # Default - NEVER force by default
)Result Dataclasses
from github_pages import GenerationResult, ValidationResult, DeploymentResult
# GenerationResult fields:
# - success: bool
# - site_dir: Path
# - pages: list[str]
# - errors: list[str]
# - warnings: list[str]
# - config_file: Path | None
# ValidationResult fields:
# - passed: bool
# - issues: list[ValidationIssue]
# - pass1_coverage: float (target: 100%)
# - pass2_clarity_score: float (target: >= 80%)
# - pass3_grounded_pct: float (target: >= 95%)
# DeploymentResult fields:
# - success: bool
# - branch: str (always "gh-pages")
# - commit_sha: str | None
# - url: str | None (GitHub Pages URL)
# - errors: list[str]Main Functions
from github_pages import generate_site, validate_site, deploy_site, preview_locally
# Generate documentation site
result = generate_site(site_config)
if not result.success:
print(f"Generation failed: {result.errors}")
# Validate documentation quality
validation = validate_site("site")
if not validation.passed:
for issue in validation.issues:
print(f"[{issue.severity}] {issue.message}")
if issue.suggestion:
print(f" Suggestion: {issue.suggestion}")
# Deploy to GitHub Pages
deployment = deploy_site(deploy_config)
if deployment.success:
print(f"Deployed to {deployment.url}")
# Start local preview server (blocks)
preview_locally("mkdocs.yml", port=8000)Three-Pass Validation
The validator implements three distinct validation passes:
Pass 1: Coverage (Target: 100%)
- Verifies all specified features are documented
- If no features specified, checks that content exists
- Creates issues for undocumented features
Pass 2: Clarity (Target: >= 80%)
- Navigation depth: <= 3 levels recommended
- Heading quality: Descriptive headings score higher
- Link quality: Contextful links (not "click here")
- Structure: No walls of text (paragraphs > 300 words)
Scoring weights:
- Navigation: 20%
- Headings: 30%
- Links: 20%
- Structure: 30%
Pass 3: Reality (Target: >= 95%)
- No future tense: "will be", "coming soon" (unless in [PLANNED] section)
- No TODOs: Unfinished work markers
- No placeholders: foo/bar examples in code blocks
Content marked with [PLANNED] is excluded from reality checks.
Usage Example
from github_pages import (
SiteConfig,
DeploymentConfig,
generate_site,
validate_site,
deploy_site,
)
# 1. Generate
config = SiteConfig(
project_name="My Project",
project_url="https://github.com/myorg/myproject",
)
gen_result = generate_site(config)
if not gen_result.success:
raise RuntimeError(f"Generation failed: {gen_result.errors}")
# 2. Validate
val_result = validate_site(gen_result.site_dir)
print(f"Coverage: {val_result.pass1_coverage}%")
print(f"Clarity: {val_result.pass2_clarity_score}%")
print(f"Grounded: {val_result.pass3_grounded_pct}%")
if not val_result.passed:
print("Validation issues:")
for issue in val_result.issues:
print(f" [{issue.pass_number}] {issue.message}")
# 3. Deploy (only if validation passes)
if val_result.passed:
deploy_config = DeploymentConfig(
site_dir=str(gen_result.site_dir),
)
deploy_result = deploy_site(deploy_config)
if deploy_result.success:
print(f"Deployed to: {deploy_result.url}")
else:
print(f"Deployment failed: {deploy_result.errors}")Dependencies
Required packages:
mkdocs>=1.5.0mkdocs-material>=9.5.0pyyaml
Install with:
pip install mkdocs mkdocs-material pyyamlCI/CD Integration
Example GitHub Actions workflow:
name: Deploy Docs
on:
push:
branches: [main]
paths: ["docs/**", "README.md"]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install mkdocs mkdocs-material pyyaml
- name: Generate and validate
run: |
python -c "
from github_pages import SiteConfig, generate_site, validate_site
config = SiteConfig(
project_name='${{ github.repository }}',
project_url='https://github.com/${{ github.repository }}',
)
result = generate_site(config)
assert result.success, f'Generation failed: {result.errors}'
validation = validate_site(result.site_dir)
for issue in validation.issues:
print(f'[{issue.severity}] {issue.message}')
assert validation.passed, 'Validation failed'
"
- name: Deploy
run: |
git config user.name github-actions
git config user.email github-actions@github.com
mkdocs gh-deploy --forceTesting
Run the test suite:
cd .claude/skills/documentation-writing
PYTHONPATH=. python -m pytest github_pages/tests/ -vTest structure follows the testing pyramid:
- 60% unit tests (fast, mocked dependencies)
- 30% integration tests (multiple components)
- 10% end-to-end tests (complete workflows)
Error Handling
All functions provide clear error handling:
generate_site: RaisesTypeErrorfor None config,FileNotFoundErrorfor missing docsvalidate_site: RaisesFileNotFoundErrorfor missing site directorydeploy_site: RaisesTypeErrorfor None config,ValueErrorfor missing/empty site
Results include error lists for non-fatal issues that don't prevent operation.
Safety Features
- Never force push by default:
force_push=Falsein DeploymentConfig - Branch switching with rollback: Returns to original branch on failure
- Uncommitted changes warning: Detects dirty working directory
- Safe subprocess handling: Timeouts and error capture for external commands
"""Tests for GitHub Pages documentation generation module."""
"""Shared fixtures for GitHub Pages tests."""
from pathlib import Path
import pytest
@pytest.fixture
def tmp_project_root(tmp_path: Path) -> Path:
"""Create a temporary project root with basic structure."""
# Create docs directory with content
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
# Create index.md
index_md = docs_dir / "index.md"
index_md.write_text("""# Test Project Documentation
Welcome to the test project.
## Features
- Feature one description
- Feature two description
## Getting Started
Follow these steps to get started.
""")
# Create a reference section
reference_dir = docs_dir / "reference"
reference_dir.mkdir()
api_md = reference_dir / "api.md"
api_md.write_text("""# API Reference
## Authentication
Use the `authenticate()` function.
## Endpoints
### GET /users
Returns list of users.
### POST /users
Creates a new user.
""")
# Create README.md
readme = tmp_path / "README.md"
readme.write_text("""# Test Project
This is the test project README.
""")
# Initialize as git repo
git_dir = tmp_path / ".git"
git_dir.mkdir()
return tmp_path
@pytest.fixture
def tmp_docs_dir(tmp_project_root: Path) -> Path:
"""Return the docs directory from the project root."""
return tmp_project_root / "docs"
@pytest.fixture
def tmp_site_dir(tmp_path: Path) -> Path:
"""Create a temporary generated site directory."""
site_dir = tmp_path / "site"
site_dir.mkdir()
# Create basic HTML structure
(site_dir / "index.html").write_text("""<!DOCTYPE html>
<html>
<head><title>Test</title></head>
<body><h1>Test Project</h1></body>
</html>
""")
# Create a subdirectory with content
reference_dir = site_dir / "reference"
reference_dir.mkdir()
(reference_dir / "api.html").write_text("""<!DOCTYPE html>
<html>
<head><title>API Reference</title></head>
<body><h1>API Reference</h1></body>
</html>
""")
return site_dir
@pytest.fixture
def sample_markdown_files(tmp_path: Path) -> list[Path]:
"""Create sample markdown files for testing navigation generation."""
docs_dir = tmp_path / "docs"
docs_dir.mkdir(exist_ok=True)
files = []
# Root level files
index = docs_dir / "index.md"
index.write_text("# Home\n\nWelcome page.")
files.append(index)
getting_started = docs_dir / "getting-started.md"
getting_started.write_text("# Getting Started\n\nHow to begin.")
files.append(getting_started)
# Tutorial section
tutorials_dir = docs_dir / "tutorials"
tutorials_dir.mkdir()
tutorial1 = tutorials_dir / "basic.md"
tutorial1.write_text("# Basic Tutorial\n\nBasic tutorial content.")
files.append(tutorial1)
# Reference section
reference_dir = docs_dir / "reference"
reference_dir.mkdir()
cli_ref = reference_dir / "cli.md"
cli_ref.write_text("# CLI Reference\n\nCommand line reference.")
files.append(cli_ref)
return files
@pytest.fixture
def tmp_git_repo(tmp_path: Path) -> Path:
"""Create a temporary directory that simulates a git repo."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
# Create .git directory (basic structure)
git_dir = repo_dir / ".git"
git_dir.mkdir()
(git_dir / "HEAD").write_text("ref: refs/heads/main\n")
refs_dir = git_dir / "refs" / "heads"
refs_dir.mkdir(parents=True)
# Create site directory
site_dir = repo_dir / "site"
site_dir.mkdir()
(site_dir / "index.html").write_text("<html><body>Test</body></html>")
return repo_dir
"""Tests for deployer.py module."""
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from github_pages import DeploymentConfig, DeploymentResult
from github_pages.deployer import (
_branch_exists,
_check_git_status,
_construct_pages_url,
_get_current_branch,
_get_repo_url,
_run_git_command,
_switch_branch,
deploy_site,
)
class TestDeploySite:
"""Tests for deploy_site function."""
def test_raises_type_error_for_none_config(self):
"""Test that None config raises TypeError."""
with pytest.raises(TypeError, match="Config cannot be None"):
deploy_site(None)
def test_raises_value_error_for_missing_site_dir(self, tmp_path: Path):
"""Test that missing site directory raises ValueError."""
config = DeploymentConfig(
site_dir=str(tmp_path / "nonexistent"),
repo_path=str(tmp_path),
)
with pytest.raises(ValueError, match="not found"):
deploy_site(config)
def test_raises_value_error_for_empty_site_dir(self, tmp_path: Path):
"""Test that empty site directory raises ValueError."""
site_dir = tmp_path / "site"
site_dir.mkdir()
config = DeploymentConfig(
site_dir=str(site_dir),
repo_path=str(tmp_path),
)
with pytest.raises(ValueError, match="empty"):
deploy_site(config)
@patch("github_pages.deployer._run_git_command")
@patch("github_pages.deployer._check_git_status")
@patch("github_pages.deployer._get_current_branch")
@patch("github_pages.deployer._get_repo_url")
@patch("github_pages.deployer._branch_exists")
def test_returns_deployment_result(
self,
mock_branch_exists: MagicMock,
mock_get_repo_url: MagicMock,
mock_get_branch: MagicMock,
mock_git_status: MagicMock,
mock_git_cmd: MagicMock,
tmp_git_repo: Path,
):
"""Test that DeploymentResult is returned."""
mock_git_status.return_value = True
mock_get_branch.return_value = "main"
mock_get_repo_url.return_value = "https://github.com/user/repo"
mock_branch_exists.return_value = False
# Mock git commands
mock_git_cmd.return_value = MagicMock(
returncode=0,
stdout="abc123\n",
stderr="",
)
site_dir = tmp_git_repo / "site"
config = DeploymentConfig(
site_dir=str(site_dir),
repo_path=str(tmp_git_repo),
)
result = deploy_site(config)
assert isinstance(result, DeploymentResult)
assert hasattr(result, "success")
assert hasattr(result, "branch")
assert hasattr(result, "commit_sha")
assert hasattr(result, "url")
assert hasattr(result, "errors")
@patch("github_pages.deployer._run_git_command")
@patch("github_pages.deployer._check_git_status")
@patch("github_pages.deployer._get_current_branch")
@patch("github_pages.deployer._get_repo_url")
@patch("github_pages.deployer._branch_exists")
def test_uses_gh_pages_branch(
self,
mock_branch_exists: MagicMock,
mock_get_repo_url: MagicMock,
mock_get_branch: MagicMock,
mock_git_status: MagicMock,
mock_git_cmd: MagicMock,
tmp_git_repo: Path,
):
"""Test that gh-pages branch is used."""
mock_git_status.return_value = True
mock_get_branch.return_value = "main"
mock_get_repo_url.return_value = "https://github.com/user/repo"
mock_branch_exists.return_value = False
mock_git_cmd.return_value = MagicMock(
returncode=0,
stdout="abc123\n",
stderr="",
)
site_dir = tmp_git_repo / "site"
config = DeploymentConfig(
site_dir=str(site_dir),
repo_path=str(tmp_git_repo),
)
result = deploy_site(config)
assert result.branch == "gh-pages"
@patch("github_pages.deployer._check_git_status")
def test_git_status_error_handled(
self,
mock_git_status: MagicMock,
tmp_git_repo: Path,
):
"""Test that git status error is handled."""
mock_git_status.side_effect = Exception("Git error")
site_dir = tmp_git_repo / "site"
config = DeploymentConfig(
site_dir=str(site_dir),
repo_path=str(tmp_git_repo),
)
result = deploy_site(config)
assert result.success is False
assert len(result.errors) > 0
def test_force_push_disabled_by_default(self, tmp_path: Path):
"""Test that force push is disabled by default."""
config = DeploymentConfig(
site_dir=str(tmp_path),
repo_path=str(tmp_path),
)
assert config.force_push is False
class TestRunGitCommand:
"""Tests for _run_git_command helper."""
def test_runs_git_command(self, tmp_path: Path):
"""Test that git command is executed."""
# This will fail but we're testing it runs
try:
result = _run_git_command(tmp_path, ["status"], check=False)
# If it succeeds, check structure
assert hasattr(result, "returncode")
except Exception:
# Git not available or not a repo - expected
pass
def test_captures_output(self, tmp_path: Path):
"""Test output capture."""
try:
result = _run_git_command(
tmp_path,
["status"],
capture_output=True,
check=False,
)
assert hasattr(result, "stdout")
assert hasattr(result, "stderr")
except Exception:
pass
class TestCheckGitStatus:
"""Tests for _check_git_status helper."""
@patch("github_pages.deployer._run_git_command")
def test_clean_repo_returns_true(self, mock_git_cmd: MagicMock, tmp_path: Path):
"""Test that clean repo returns True."""
mock_git_cmd.return_value = MagicMock(stdout="", returncode=0)
result = _check_git_status(tmp_path)
assert result is True
@patch("github_pages.deployer._run_git_command")
def test_dirty_repo_returns_false(self, mock_git_cmd: MagicMock, tmp_path: Path):
"""Test that dirty repo returns False."""
mock_git_cmd.return_value = MagicMock(
stdout="M modified_file.py\n",
returncode=0,
)
result = _check_git_status(tmp_path)
assert result is False
class TestGetCurrentBranch:
"""Tests for _get_current_branch helper."""
@patch("github_pages.deployer._run_git_command")
def test_returns_branch_name(self, mock_git_cmd: MagicMock, tmp_path: Path):
"""Test that branch name is returned."""
mock_git_cmd.return_value = MagicMock(stdout="main\n", returncode=0)
result = _get_current_branch(tmp_path)
assert result == "main"
@patch("github_pages.deployer._run_git_command")
def test_strips_whitespace(self, mock_git_cmd: MagicMock, tmp_path: Path):
"""Test that whitespace is stripped."""
mock_git_cmd.return_value = MagicMock(stdout=" feature/branch \n", returncode=0)
result = _get_current_branch(tmp_path)
assert result == "feature/branch"
class TestGetRepoUrl:
"""Tests for _get_repo_url helper."""
@patch("github_pages.deployer._run_git_command")
def test_returns_repo_url(self, mock_git_cmd: MagicMock, tmp_path: Path):
"""Test that repo URL is returned."""
mock_git_cmd.return_value = MagicMock(
stdout="https://github.com/user/repo.git\n",
returncode=0,
)
result = _get_repo_url(tmp_path)
assert result == "https://github.com/user/repo.git"
class TestBranchExists:
"""Tests for _branch_exists helper."""
@patch("github_pages.deployer._run_git_command")
def test_existing_branch_returns_true(
self,
mock_git_cmd: MagicMock,
tmp_path: Path,
):
"""Test that existing branch returns True."""
mock_git_cmd.return_value = MagicMock(returncode=0)
result = _branch_exists(tmp_path, "main")
assert result is True
@patch("github_pages.deployer._run_git_command")
def test_nonexistent_branch_returns_false(
self,
mock_git_cmd: MagicMock,
tmp_path: Path,
):
"""Test that nonexistent branch returns False."""
mock_git_cmd.return_value = MagicMock(returncode=1)
result = _branch_exists(tmp_path, "nonexistent")
assert result is False
class TestSwitchBranch:
"""Tests for _switch_branch helper."""
@patch("github_pages.deployer._run_git_command")
def test_switches_branch(self, mock_git_cmd: MagicMock, tmp_path: Path):
"""Test that branch is switched."""
_switch_branch(tmp_path, "feature")
mock_git_cmd.assert_called_once()
call_args = mock_git_cmd.call_args[0]
assert "checkout" in call_args[1]
assert "feature" in call_args[1]
class TestConstructPagesUrl:
"""Tests for _construct_pages_url helper."""
def test_https_url(self):
"""Test URL construction from HTTPS format."""
url = _construct_pages_url("https://github.com/user/repo")
assert url == "https://user.github.io/repo/"
def test_https_url_with_git_extension(self):
"""Test URL construction with .git extension."""
url = _construct_pages_url("https://github.com/user/repo.git")
assert url == "https://user.github.io/repo/"
def test_ssh_url(self):
"""Test URL construction from SSH format."""
url = _construct_pages_url("git@github.com:user/repo.git")
assert url == "https://user.github.io/repo/"
def test_trailing_slash_handled(self):
"""Test that trailing slash is handled."""
url = _construct_pages_url("https://github.com/user/repo/")
assert url == "https://user.github.io/repo/"
class TestDeploymentConfig:
"""Tests for DeploymentConfig dataclass."""
def test_default_values(self):
"""Test default configuration values."""
config = DeploymentConfig(site_dir="/tmp/site")
assert config.repo_path == "."
assert config.commit_message == "Update docs"
assert config.force_push is False
def test_custom_values(self):
"""Test custom configuration values."""
config = DeploymentConfig(
site_dir="/tmp/site",
repo_path="/tmp/repo",
commit_message="Deploy documentation",
force_push=True,
)
assert config.site_dir == "/tmp/site"
assert config.repo_path == "/tmp/repo"
assert config.commit_message == "Deploy documentation"
assert config.force_push is True
class TestDeploymentResult:
"""Tests for DeploymentResult dataclass."""
def test_success_result(self):
"""Test successful deployment result."""
result = DeploymentResult(
success=True,
branch="gh-pages",
commit_sha="abc123",
url="https://user.github.io/repo/",
errors=[],
)
assert result.success is True
assert result.commit_sha == "abc123"
assert result.url is not None
assert len(result.errors) == 0
def test_failure_result(self):
"""Test failed deployment result."""
result = DeploymentResult(
success=False,
branch="gh-pages",
commit_sha=None,
url=None,
errors=["Push failed"],
)
assert result.success is False
assert result.commit_sha is None
assert result.url is None
assert len(result.errors) > 0
"""Tests for generator.py module."""
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from github_pages import GenerationResult, SiteConfig
from github_pages.generator import (
discover_commands,
discover_content,
discover_readme,
generate_navigation,
generate_site,
preview_locally,
)
class TestGenerateSite:
"""Tests for generate_site function."""
def test_raises_type_error_for_none_config(self):
"""Test that None config raises TypeError."""
with pytest.raises(TypeError, match="Config cannot be None"):
generate_site(None)
def test_raises_file_not_found_for_missing_docs(self, tmp_path: Path):
"""Test that missing docs directory raises FileNotFoundError."""
config = SiteConfig(
project_name="Test",
project_url="https://github.com/user/repo",
docs_dir=str(tmp_path / "nonexistent"),
)
with pytest.raises(FileNotFoundError, match="not found"):
generate_site(config)
@patch("github_pages.generator.subprocess.run")
def test_successful_generation(
self,
mock_run: MagicMock,
tmp_project_root: Path,
):
"""Test successful site generation."""
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
config = SiteConfig(
project_name="Test Project",
project_url="https://github.com/user/repo",
docs_dir=str(tmp_project_root / "docs"),
output_dir=str(tmp_project_root / "site"),
)
result = generate_site(config)
assert isinstance(result, GenerationResult)
assert result.config_file is not None
# mkdocs.yml should be created
assert (tmp_project_root / "mkdocs.yml").exists()
@patch("github_pages.generator.subprocess.run")
def test_mkdocs_failure_returns_error(
self,
mock_run: MagicMock,
tmp_project_root: Path,
):
"""Test that MkDocs failure is handled."""
mock_run.return_value = MagicMock(
returncode=1,
stdout="",
stderr="Build error",
)
config = SiteConfig(
project_name="Test",
project_url="https://github.com/user/repo",
docs_dir=str(tmp_project_root / "docs"),
output_dir=str(tmp_project_root / "site"),
)
result = generate_site(config)
assert result.success is False
assert len(result.errors) > 0
@patch("github_pages.generator.subprocess.run")
def test_readme_copied_to_index(
self,
mock_run: MagicMock,
tmp_path: Path,
):
"""Test that README is copied to index.md if index doesn't exist."""
# Create docs dir without index
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
(docs_dir / "guide.md").write_text("# Guide")
# Create README
readme = tmp_path / "README.md"
readme.write_text("# Project README")
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
config = SiteConfig(
project_name="Test",
project_url="https://github.com/user/repo",
docs_dir=str(docs_dir),
output_dir=str(tmp_path / "site"),
)
generate_site(config)
# index.md should be created from README
index_path = docs_dir / "index.md"
assert index_path.exists()
assert "Project README" in index_path.read_text()
@patch("github_pages.generator.subprocess.run")
def test_timeout_handled(
self,
mock_run: MagicMock,
tmp_project_root: Path,
):
"""Test that timeout is handled gracefully."""
mock_run.side_effect = subprocess.TimeoutExpired("mkdocs", 120)
config = SiteConfig(
project_name="Test",
project_url="https://github.com/user/repo",
docs_dir=str(tmp_project_root / "docs"),
output_dir=str(tmp_project_root / "site"),
)
result = generate_site(config)
assert result.success is False
assert any("timed out" in err for err in result.errors)
class TestDiscoverContent:
"""Tests for discover_content function."""
def test_empty_directory(self, tmp_path: Path):
"""Test discovering content in empty directory."""
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
files = discover_content(docs_dir)
assert files == []
def test_nonexistent_directory(self, tmp_path: Path):
"""Test discovering content in nonexistent directory."""
files = discover_content(tmp_path / "nonexistent")
assert files == []
def test_finds_markdown_files(self, tmp_docs_dir: Path):
"""Test that markdown files are discovered."""
files = discover_content(tmp_docs_dir)
assert len(files) > 0
assert all(f.suffix == ".md" for f in files)
def test_index_md_sorted_first(self, tmp_path: Path):
"""Test that index.md is sorted first."""
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
(docs_dir / "zebra.md").write_text("# Zebra")
(docs_dir / "alpha.md").write_text("# Alpha")
(docs_dir / "index.md").write_text("# Index")
files = discover_content(docs_dir)
assert files[0].name == "index.md"
def test_finds_files_in_subdirectories(self, sample_markdown_files: list[Path]):
"""Test that files in subdirectories are found."""
# Get the docs dir from the first file
docs_dir = sample_markdown_files[0].parent
files = discover_content(docs_dir)
# Should find files in subdirectories
paths_str = [str(f) for f in files]
assert any("tutorials" in p or "reference" in p for p in paths_str)
class TestDiscoverReadme:
"""Tests for discover_readme function."""
def test_finds_readme_md(self, tmp_path: Path):
"""Test finding README.md."""
readme = tmp_path / "README.md"
readme.write_text("# Project")
result = discover_readme(tmp_path)
assert result == readme
def test_finds_lowercase_readme(self, tmp_path: Path):
"""Test finding readme.md (lowercase)."""
readme = tmp_path / "readme.md"
readme.write_text("# Project")
result = discover_readme(tmp_path)
# On case-insensitive filesystems, the canonical path may differ
assert result is not None
assert result.name.lower() == "readme.md"
def test_returns_none_if_no_readme(self, tmp_path: Path):
"""Test returning None when no README exists."""
result = discover_readme(tmp_path)
assert result is None
class TestDiscoverCommands:
"""Tests for discover_commands function."""
@patch("github_pages.generator.subprocess.run")
def test_discovers_amplihack_command(self, mock_run: MagicMock):
"""Test discovering amplihack command help."""
mock_run.return_value = MagicMock(
returncode=0,
stdout="amplihack help text",
stderr="",
)
commands = discover_commands()
# May or may not find commands depending on system
assert isinstance(commands, dict)
@patch("github_pages.generator.subprocess.run")
def test_handles_missing_command(self, mock_run: MagicMock):
"""Test handling when amplihack is not found."""
mock_run.side_effect = FileNotFoundError()
commands = discover_commands()
assert commands == {}
class TestGenerateNavigation:
"""Tests for generate_navigation function."""
def test_empty_files(self):
"""Test navigation with empty file list."""
nav = generate_navigation([])
assert isinstance(nav, dict)
def test_custom_structure_used(self):
"""Test that custom navigation structure is used when provided."""
custom = {"Home": "index.md", "Guide": "guide.md"}
nav = generate_navigation([], custom_structure=custom)
assert nav == custom
def test_auto_generation(self, sample_markdown_files: list[Path]):
"""Test auto-generation of navigation."""
nav = generate_navigation(sample_markdown_files)
# Should be a dict
assert isinstance(nav, dict)
class TestPreviewLocally:
"""Tests for preview_locally function."""
@patch("github_pages.generator.subprocess.run")
def test_starts_server(self, mock_run: MagicMock, tmp_path: Path):
"""Test that preview starts MkDocs server."""
config_path = tmp_path / "mkdocs.yml"
config_path.write_text("site_name: Test")
preview_locally(config_path, port=8000)
mock_run.assert_called_once()
call_args = mock_run.call_args[0][0]
assert "mkdocs" in call_args
assert "serve" in call_args
@patch("github_pages.generator.subprocess.run")
def test_custom_port(self, mock_run: MagicMock, tmp_path: Path):
"""Test preview with custom port."""
config_path = tmp_path / "mkdocs.yml"
config_path.write_text("site_name: Test")
preview_locally(config_path, port=9000)
call_args = mock_run.call_args[0][0]
assert "9000" in str(call_args)
"""Integration tests for GitHub Pages module."""
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from github_pages import (
DeploymentConfig,
DeploymentResult,
GenerationResult,
SiteConfig,
ValidationResult,
deploy_site,
generate_site,
validate_site,
)
class TestGenerateValidateWorkflow:
"""Integration tests for generate -> validate workflow."""
@patch("github_pages.generator.subprocess.run")
def test_generate_then_validate(
self,
mock_run: MagicMock,
tmp_project_root: Path,
):
"""Test generating a site then validating it."""
# Mock successful MkDocs build
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
# Create output directory with content
site_dir = tmp_project_root / "site"
site_dir.mkdir(exist_ok=True)
(site_dir / "index.html").write_text("<html><body>Test</body></html>")
(site_dir / "docs.md").write_text("# Documentation\n\nComplete guide.")
# Generate
gen_config = SiteConfig(
project_name="Test Project",
project_url="https://github.com/user/repo",
docs_dir=str(tmp_project_root / "docs"),
output_dir=str(site_dir),
)
gen_result = generate_site(gen_config)
# Validate
val_result = validate_site(site_dir)
assert isinstance(gen_result, GenerationResult)
assert isinstance(val_result, ValidationResult)
@patch("github_pages.generator.subprocess.run")
def test_validation_results_match_expectations(
self,
mock_run: MagicMock,
tmp_project_root: Path,
):
"""Test that validation results contain expected data."""
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
site_dir = tmp_project_root / "site"
site_dir.mkdir(exist_ok=True)
# Create quality documentation
(site_dir / "index.md").write_text("""
# Project Documentation
## Getting Started
This guide explains how to get started.
### Installation
Use pip to install the package.
### Configuration
Configure settings in config.yml.
## API Reference
See the [API documentation](api.md) for details.
""")
val_result = validate_site(site_dir)
# Should have all three pass scores
assert val_result.pass1_coverage >= 0
assert val_result.pass2_clarity_score >= 0
assert val_result.pass3_grounded_pct >= 0
class TestFullWorkflow:
"""Integration tests for complete workflow."""
@patch("github_pages.generator.subprocess.run")
@patch("github_pages.deployer._run_git_command")
@patch("github_pages.deployer._check_git_status")
@patch("github_pages.deployer._get_current_branch")
@patch("github_pages.deployer._get_repo_url")
@patch("github_pages.deployer._branch_exists")
def test_full_generate_validate_deploy(
self,
mock_branch_exists: MagicMock,
mock_get_repo_url: MagicMock,
mock_get_branch: MagicMock,
mock_git_status: MagicMock,
mock_git_cmd: MagicMock,
mock_mkdocs: MagicMock,
tmp_project_root: Path,
):
"""Test complete workflow: generate -> validate -> deploy."""
# Setup mocks
mock_mkdocs.return_value = MagicMock(returncode=0, stdout="", stderr="")
mock_git_status.return_value = True
mock_get_branch.return_value = "main"
mock_get_repo_url.return_value = "https://github.com/user/repo"
mock_branch_exists.return_value = False
mock_git_cmd.return_value = MagicMock(
returncode=0,
stdout="abc123\n",
stderr="",
)
# Create site dir with content
site_dir = tmp_project_root / "site"
site_dir.mkdir(exist_ok=True)
(site_dir / "index.html").write_text("<html></html>")
(site_dir / "docs.md").write_text("# Docs\n\nContent here.")
# Initialize git
git_dir = tmp_project_root / ".git"
git_dir.mkdir(exist_ok=True)
# Step 1: Generate
gen_config = SiteConfig(
project_name="Test",
project_url="https://github.com/user/repo",
docs_dir=str(tmp_project_root / "docs"),
output_dir=str(site_dir),
)
gen_result = generate_site(gen_config)
# Step 2: Validate
val_result = validate_site(site_dir)
# Step 3: Deploy (only if validation passes thresholds)
deploy_config = DeploymentConfig(
site_dir=str(site_dir),
repo_path=str(tmp_project_root),
)
deploy_result = deploy_site(deploy_config)
# All should return results
assert isinstance(gen_result, GenerationResult)
assert isinstance(val_result, ValidationResult)
assert isinstance(deploy_result, DeploymentResult)
class TestErrorHandling:
"""Integration tests for error handling."""
def test_generate_with_invalid_docs_dir(self, tmp_path: Path):
"""Test error handling for invalid docs directory."""
config = SiteConfig(
project_name="Test",
project_url="https://github.com/user/repo",
docs_dir=str(tmp_path / "nonexistent"),
)
with pytest.raises(FileNotFoundError):
generate_site(config)
def test_validate_with_invalid_site_dir(self, tmp_path: Path):
"""Test error handling for invalid site directory."""
with pytest.raises(FileNotFoundError):
validate_site(tmp_path / "nonexistent")
def test_deploy_with_invalid_site_dir(self, tmp_path: Path):
"""Test error handling for invalid site directory."""
config = DeploymentConfig(
site_dir=str(tmp_path / "nonexistent"),
)
with pytest.raises(ValueError):
deploy_site(config)
class TestPublicAPI:
"""Tests for public API consistency."""
def test_all_exports_available(self):
"""Test that all public exports are available."""
from github_pages import (
DeploymentConfig,
DeploymentResult,
GenerationResult,
SiteConfig,
ValidationIssue,
ValidationResult,
deploy_site,
generate_site,
preview_locally,
validate_site,
)
# All should be importable
assert SiteConfig is not None
assert DeploymentConfig is not None
assert GenerationResult is not None
assert ValidationResult is not None
assert ValidationIssue is not None
assert DeploymentResult is not None
assert callable(generate_site)
assert callable(validate_site)
assert callable(deploy_site)
assert callable(preview_locally)
def test_config_dataclasses_have_defaults(self):
"""Test that config dataclasses have sensible defaults."""
site_config = SiteConfig(
project_name="Test",
project_url="https://github.com/user/repo",
)
assert site_config.docs_dir == "docs"
assert site_config.output_dir == "site"
assert site_config.theme == "material"
deploy_config = DeploymentConfig(site_dir="/tmp/site")
assert deploy_config.repo_path == "."
assert deploy_config.force_push is False
class TestDataclasses:
"""Tests for result dataclasses."""
def test_generation_result_fields(self):
"""Test GenerationResult has all required fields."""
result = GenerationResult(
success=True,
site_dir=Path("/tmp/site"),
pages=["index.html"],
errors=[],
warnings=[],
config_file=Path("/tmp/mkdocs.yml"),
)
assert result.success is True
assert isinstance(result.site_dir, Path)
assert isinstance(result.pages, list)
assert isinstance(result.errors, list)
assert isinstance(result.warnings, list)
def test_validation_result_fields(self):
"""Test ValidationResult has all required fields."""
result = ValidationResult(
passed=True,
issues=[],
pass1_coverage=100.0,
pass2_clarity_score=85.0,
pass3_grounded_pct=98.0,
)
assert result.passed is True
assert result.pass1_coverage == 100.0
assert result.pass2_clarity_score == 85.0
assert result.pass3_grounded_pct == 98.0
def test_deployment_result_fields(self):
"""Test DeploymentResult has all required fields."""
result = DeploymentResult(
success=True,
branch="gh-pages",
commit_sha="abc123",
url="https://user.github.io/repo/",
errors=[],
)
assert result.success is True
assert result.branch == "gh-pages"
assert result.commit_sha is not None
assert result.url is not None
"""Tests for mkdocs_config.py module."""
from pathlib import Path
import pytest
from github_pages.mkdocs_config import (
_construct_site_url,
_extract_repo_info,
_extract_repo_name,
_format_page_name,
_format_section_name,
build_material_theme_config,
build_mkdocs_config,
generate_nav_structure,
validate_config,
write_mkdocs_yaml,
)
class TestBuildMkdocsConfig:
"""Tests for build_mkdocs_config function."""
def test_basic_config(self, tmp_path: Path):
"""Test building basic configuration."""
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
(docs_dir / "index.md").write_text("# Home")
config = build_mkdocs_config(
project_name="Test Project",
project_url="https://github.com/user/repo",
docs_dir=str(docs_dir),
)
assert config["site_name"] == "Test Project"
assert config["repo_url"] == "https://github.com/user/repo"
assert "theme" in config
assert "nav" in config
assert "plugins" in config
def test_config_with_custom_theme_features(self, tmp_path: Path):
"""Test configuration with custom theme features."""
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
(docs_dir / "index.md").write_text("# Home")
custom_features = ["navigation.tabs", "search.suggest"]
config = build_mkdocs_config(
project_name="Test",
project_url="https://github.com/user/repo",
docs_dir=str(docs_dir),
theme_features=custom_features,
)
assert config["theme"]["features"] == custom_features
def test_config_with_custom_nav(self, tmp_path: Path):
"""Test configuration with custom navigation structure."""
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
custom_nav = [{"Home": "index.md"}, {"Guide": "guide.md"}]
config = build_mkdocs_config(
project_name="Test",
project_url="https://github.com/user/repo",
docs_dir=str(docs_dir),
nav_structure=custom_nav,
)
assert config["nav"] == custom_nav
def test_site_url_constructed_from_repo(self):
"""Test that site_url is properly constructed from repo URL."""
config = build_mkdocs_config(
project_name="Test",
project_url="https://github.com/myuser/myrepo",
)
assert config["site_url"] == "https://myuser.github.io/myrepo/"
def test_repo_name_extracted(self):
"""Test that repo name is extracted correctly."""
config = build_mkdocs_config(
project_name="Test",
project_url="https://github.com/owner/repo-name",
)
assert config["repo_name"] == "owner/repo-name"
class TestBuildMaterialThemeConfig:
"""Tests for build_material_theme_config function."""
def test_default_theme_config(self):
"""Test building default theme configuration."""
config = build_material_theme_config()
assert config["name"] == "material"
assert "features" in config
assert "palette" in config
assert config["palette"]["primary"] == "indigo"
def test_custom_features(self):
"""Test theme config with custom features."""
features = ["navigation.tabs"]
config = build_material_theme_config(features=features)
assert config["features"] == features
def test_default_features_included(self):
"""Test that default features are sensible."""
config = build_material_theme_config()
assert "navigation.tabs" in config["features"]
assert "search.highlight" in config["features"]
class TestGenerateNavStructure:
"""Tests for generate_nav_structure function."""
def test_empty_files_returns_default(self):
"""Test that empty file list returns default nav."""
nav = generate_nav_structure([])
assert nav == [{"Home": "index.md"}]
def test_single_index_file(self, tmp_path: Path):
"""Test navigation with just index.md."""
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
index = docs_dir / "index.md"
index.write_text("# Home")
nav = generate_nav_structure([index])
assert {"Home": "index.md"} in nav
def test_files_in_subdirectories(self, sample_markdown_files: list[Path]):
"""Test navigation with files in subdirectories."""
nav = generate_nav_structure(sample_markdown_files)
# Should have Home
home_items = [item for item in nav if "Home" in item]
assert len(home_items) > 0
# Should have sections
nav_str = str(nav)
assert "Tutorials" in nav_str or "tutorials" in nav_str.lower()
def test_diataxis_ordering(self, tmp_path: Path):
"""Test that Diataxis sections are ordered correctly."""
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
# Create sections in non-Diataxis order
for section in ["concepts", "howto", "reference", "tutorials"]:
section_dir = docs_dir / section
section_dir.mkdir()
(section_dir / "index.md").write_text(f"# {section}")
files = list(docs_dir.rglob("*.md"))
nav = generate_nav_structure(files)
# Check order - Tutorials should come before Concepts
nav_str = str(nav)
tutorials_pos = nav_str.find("Tutorials")
concepts_pos = nav_str.find("Concepts")
# If both exist, Tutorials should come first
if tutorials_pos >= 0 and concepts_pos >= 0:
assert tutorials_pos < concepts_pos
class TestWriteMkdocsYaml:
"""Tests for write_mkdocs_yaml function."""
def test_writes_valid_yaml(self, tmp_path: Path):
"""Test that valid YAML is written."""
config = {
"site_name": "Test",
"theme": {"name": "material"},
}
output_path = tmp_path / "mkdocs.yml"
result = write_mkdocs_yaml(config, output_path)
assert result == output_path
assert output_path.exists()
content = output_path.read_text()
assert "site_name: Test" in content
assert "theme:" in content
def test_preserves_key_order(self, tmp_path: Path):
"""Test that key order is preserved in YAML output."""
config = {
"first": "1",
"second": "2",
"third": "3",
}
output_path = tmp_path / "mkdocs.yml"
write_mkdocs_yaml(config, output_path)
content = output_path.read_text()
first_pos = content.find("first")
second_pos = content.find("second")
third_pos = content.find("third")
assert first_pos < second_pos < third_pos
class TestValidateConfig:
"""Tests for validate_config function."""
def test_valid_config_passes(self):
"""Test that valid config passes validation."""
config = {
"site_name": "Test",
"theme": {"name": "material"},
}
# Should not raise
validate_config(config)
def test_missing_site_name_raises(self):
"""Test that missing site_name raises ValueError."""
config = {"theme": {"name": "material"}}
with pytest.raises(ValueError, match="site_name"):
validate_config(config)
def test_missing_theme_raises(self):
"""Test that missing theme raises ValueError."""
config = {"site_name": "Test"}
with pytest.raises(ValueError, match="theme"):
validate_config(config)
def test_non_material_theme_raises(self):
"""Test that non-Material theme raises ValueError."""
config = {
"site_name": "Test",
"theme": {"name": "readthedocs"},
}
with pytest.raises(ValueError, match="material"):
validate_config(config)
class TestFormatSectionName:
"""Tests for _format_section_name function."""
def test_basic_formatting(self):
"""Test basic section name formatting."""
assert _format_section_name("getting-started") == "Getting Started"
assert _format_section_name("user_guide") == "User Guide"
def test_special_cases(self):
"""Test special case formatting."""
assert _format_section_name("api") == "API"
assert _format_section_name("api-reference") == "API Reference"
assert _format_section_name("howto") == "How-To"
assert _format_section_name("cli") == "CLI"
class TestFormatPageName:
"""Tests for _format_page_name function."""
def test_basic_formatting(self):
"""Test basic page name formatting."""
assert _format_page_name("getting-started.md") == "Getting Started"
assert _format_page_name("user_guide.md") == "User Guide"
def test_index_returns_home(self):
"""Test that index.md returns Home."""
assert _format_page_name("index.md") == "Home"
class TestExtractRepoInfo:
"""Tests for _extract_repo_info function."""
def test_https_url(self):
"""Test extracting from HTTPS URL."""
owner, repo = _extract_repo_info("https://github.com/user/repo")
assert owner == "user"
assert repo == "repo"
def test_https_url_with_git_extension(self):
"""Test extracting from HTTPS URL with .git extension."""
owner, repo = _extract_repo_info("https://github.com/user/repo.git")
assert owner == "user"
assert repo == "repo"
def test_ssh_url(self):
"""Test extracting from SSH URL."""
owner, repo = _extract_repo_info("git@github.com:user/repo.git")
assert owner == "user"
assert repo == "repo"
class TestExtractRepoName:
"""Tests for _extract_repo_name function."""
def test_returns_owner_slash_repo(self):
"""Test that owner/repo format is returned."""
name = _extract_repo_name("https://github.com/myorg/myproject")
assert name == "myorg/myproject"
class TestConstructSiteUrl:
"""Tests for _construct_site_url function."""
def test_basic_url(self):
"""Test basic GitHub Pages URL construction."""
url = _construct_site_url("https://github.com/user/repo")
assert url == "https://user.github.io/repo/"
def test_ssh_url(self):
"""Test URL construction from SSH format."""
url = _construct_site_url("git@github.com:user/repo.git")
assert url == "https://user.github.io/repo/"
"""Tests for validator.py module."""
from pathlib import Path
import pytest
from github_pages import ValidationIssue, ValidationResult
from github_pages.validator import (
ClarityResult,
CoverageResult,
RealityResult,
validate_clarity,
validate_coverage,
validate_reality,
validate_site,
)
class TestValidateSite:
"""Tests for validate_site function."""
def test_raises_file_not_found_for_missing_dir(self, tmp_path: Path):
"""Test that missing site directory raises FileNotFoundError."""
with pytest.raises(FileNotFoundError, match="not found"):
validate_site(tmp_path / "nonexistent")
def test_returns_validation_result(self, tmp_site_dir: Path):
"""Test that ValidationResult is returned."""
result = validate_site(tmp_site_dir)
assert isinstance(result, ValidationResult)
assert hasattr(result, "passed")
assert hasattr(result, "issues")
assert hasattr(result, "pass1_coverage")
assert hasattr(result, "pass2_clarity_score")
assert hasattr(result, "pass3_grounded_pct")
def test_all_passes_executed(self, tmp_site_dir: Path):
"""Test that all three passes are executed."""
result = validate_site(tmp_site_dir)
# Should have scores from all passes
assert result.pass1_coverage >= 0
assert result.pass2_clarity_score >= 0
assert result.pass3_grounded_pct >= 0
def test_passing_validation(self, tmp_site_dir: Path):
"""Test a site that should pass validation."""
# Create well-structured content
md_file = tmp_site_dir / "docs.md"
md_file.write_text("""# Documentation
## Getting Started
This is a comprehensive guide to getting started with the project.
### Installation
Install the package using pip.
### Configuration
Configure the settings in config.yml.
## Features
The system provides several useful features.
""")
result = validate_site(tmp_site_dir)
# Should have reasonable scores
assert result.pass1_coverage == 100.0 # No features specified
assert result.pass2_clarity_score >= 0
assert result.pass3_grounded_pct >= 0
class TestValidateCoverage:
"""Tests for validate_coverage (Pass 1)."""
def test_returns_coverage_result(self, tmp_site_dir: Path):
"""Test that CoverageResult is returned."""
result = validate_coverage(tmp_site_dir, [])
assert isinstance(result, CoverageResult)
assert hasattr(result, "coverage_pct")
assert hasattr(result, "missing_features")
assert hasattr(result, "issues")
def test_no_features_returns_100_percent(self, tmp_site_dir: Path):
"""Test that empty feature list returns 100% coverage."""
result = validate_coverage(tmp_site_dir, [])
assert result.coverage_pct == 100.0
assert result.missing_features == []
def test_missing_feature_detected(self, tmp_site_dir: Path):
"""Test that missing features are detected."""
result = validate_coverage(tmp_site_dir, ["Authentication", "Authorization"])
# These features are not in the test site
assert result.coverage_pct < 100.0
assert len(result.missing_features) > 0
def test_found_feature_not_in_missing(self, tmp_path: Path):
"""Test that documented features are not in missing list."""
site_dir = tmp_path / "site"
site_dir.mkdir()
# Create content with "Authentication"
(site_dir / "auth.md").write_text("# Authentication\n\nHow to authenticate.")
result = validate_coverage(site_dir, ["Authentication"])
assert "Authentication" not in result.missing_features
assert result.coverage_pct == 100.0
def test_empty_site_zero_coverage(self, tmp_path: Path):
"""Test that empty site returns 0% coverage."""
site_dir = tmp_path / "site"
site_dir.mkdir()
result = validate_coverage(site_dir, [])
# Empty site with no explicit features - should fail
assert result.coverage_pct == 0.0
def test_issues_created_for_missing_features(self, tmp_site_dir: Path):
"""Test that issues are created for missing features."""
result = validate_coverage(tmp_site_dir, ["MissingFeature"])
assert len(result.issues) > 0
assert result.issues[0].pass_number == 1
assert "MissingFeature" in result.issues[0].message
class TestValidateClarity:
"""Tests for validate_clarity (Pass 2)."""
def test_returns_clarity_result(self, tmp_site_dir: Path):
"""Test that ClarityResult is returned."""
result = validate_clarity(tmp_site_dir)
assert isinstance(result, ClarityResult)
assert hasattr(result, "clarity_score")
assert hasattr(result, "nav_depth")
assert hasattr(result, "heading_score")
assert hasattr(result, "link_quality_score")
assert hasattr(result, "passed")
def test_shallow_navigation_passes(self, tmp_path: Path):
"""Test that shallow navigation structure passes."""
site_dir = tmp_path / "site"
site_dir.mkdir()
(site_dir / "index.html").write_text("<html></html>")
result = validate_clarity(site_dir)
# Shallow structure should not trigger nav depth warning
assert result.nav_depth <= 3
def test_deep_navigation_creates_issue(self, tmp_path: Path):
"""Test that deep navigation creates an issue."""
site_dir = tmp_path / "site"
site_dir.mkdir()
# Create deep structure
deep_path = site_dir / "a" / "b" / "c" / "d" / "e"
deep_path.mkdir(parents=True)
(deep_path / "page.html").write_text("<html></html>")
result = validate_clarity(site_dir)
# Should detect deep navigation
assert result.nav_depth > 3
assert len(result.issues) > 0
def test_good_headings_score_high(self, tmp_path: Path):
"""Test that descriptive headings score well."""
site_dir = tmp_path / "site"
site_dir.mkdir()
(site_dir / "doc.md").write_text("""
# Getting Started with Authentication
## Configuring API Keys
## Handling Rate Limits
""")
result = validate_clarity(site_dir)
assert result.heading_score >= 50
def test_generic_headings_score_lower(self, tmp_path: Path):
"""Test that generic headings score lower."""
site_dir = tmp_path / "site"
site_dir.mkdir()
(site_dir / "doc.md").write_text("""
# Overview
## About
## More
## Info
""")
result = validate_clarity(site_dir)
# Generic headings should score lower
assert result.heading_score < 100
def test_good_links_score_high(self, tmp_path: Path):
"""Test that contextful links score well."""
site_dir = tmp_path / "site"
site_dir.mkdir()
(site_dir / "doc.md").write_text("""
See the [Authentication Guide](auth.md) for details.
Check out [API Reference](api.md) for endpoints.
""")
result = validate_clarity(site_dir)
assert result.link_quality_score == 100.0
def test_bad_links_score_lower(self, tmp_path: Path):
"""Test that 'click here' links score lower."""
site_dir = tmp_path / "site"
site_dir.mkdir()
(site_dir / "doc.md").write_text("""
For more info, [click here](more.md).
See [this link](other.md) for details.
""")
result = validate_clarity(site_dir)
assert result.link_quality_score < 100
class TestValidateReality:
"""Tests for validate_reality (Pass 3)."""
def test_returns_reality_result(self, tmp_site_dir: Path):
"""Test that RealityResult is returned."""
result = validate_reality(tmp_site_dir)
assert isinstance(result, RealityResult)
assert hasattr(result, "grounded_pct")
assert hasattr(result, "passed")
assert hasattr(result, "issues")
def test_empty_site_returns_100_percent(self, tmp_path: Path):
"""Test that empty site returns 100% grounded."""
site_dir = tmp_path / "site"
site_dir.mkdir()
result = validate_reality(site_dir)
assert result.grounded_pct == 100.0
assert result.passed is True
def test_future_tense_detected(self, tmp_path: Path):
"""Test that future tense is detected."""
site_dir = tmp_path / "site"
site_dir.mkdir()
(site_dir / "doc.md").write_text("""
# Roadmap
This feature will be implemented soon.
Authentication coming soon.
""")
result = validate_reality(site_dir)
assert result.grounded_pct < 100.0
assert len(result.issues) > 0
def test_future_tense_in_planned_section_allowed(self, tmp_path: Path):
"""Test that future tense in [PLANNED] section is allowed."""
site_dir = tmp_path / "site"
site_dir.mkdir()
(site_dir / "doc.md").write_text("""
# Documentation
This is current functionality.
[PLANNED]
This feature will be added in the future.
Authentication will be implemented.
""")
result = validate_reality(site_dir)
# [PLANNED] sections should be excluded from analysis
# Issues should be fewer or none
# May still have issues from HTML files, but MD should be clean
assert result is not None # Verify validation ran
def test_todo_detected(self, tmp_path: Path):
"""Test that TODO markers are detected."""
site_dir = tmp_path / "site"
site_dir.mkdir()
(site_dir / "doc.md").write_text("""
# Guide
TODO: Complete this section
""")
result = validate_reality(site_dir)
assert len(result.issues) > 0
assert any("TODO" in issue.message for issue in result.issues)
def test_placeholder_examples_detected(self, tmp_path: Path):
"""Test that foo/bar placeholders are detected."""
site_dir = tmp_path / "site"
site_dir.mkdir()
(site_dir / "doc.md").write_text("""
# Example
```python
user = foo
password = bar
```
""")
result = validate_reality(site_dir)
assert len(result.issues) > 0
assert any("placeholder" in issue.message.lower() for issue in result.issues)
def test_realistic_examples_pass(self, tmp_path: Path):
"""Test that realistic examples pass validation."""
site_dir = tmp_path / "site"
site_dir.mkdir()
(site_dir / "doc.md").write_text("""
# Example
```python
user = "admin"
password = os.environ["API_KEY"]
client.authenticate(user, password)
```
""")
result = validate_reality(site_dir)
# Should not flag realistic code
placeholder_issues = [i for i in result.issues if "placeholder" in i.message.lower()]
assert len(placeholder_issues) == 0
def test_grounded_threshold(self, tmp_path: Path):
"""Test that 95% threshold determines passed status."""
site_dir = tmp_path / "site"
site_dir.mkdir()
# Create mostly good content
(site_dir / "good.md").write_text("# Good Documentation\n\nThis is complete.")
result = validate_reality(site_dir)
if result.grounded_pct >= 95.0:
assert result.passed is True
else:
assert result.passed is False
class TestValidationIssue:
"""Tests for ValidationIssue dataclass."""
def test_issue_attributes(self, tmp_path: Path):
"""Test that issues have correct attributes."""
site_dir = tmp_path / "site"
site_dir.mkdir()
(site_dir / "doc.md").write_text("TODO: fix this")
result = validate_reality(site_dir)
if result.issues:
issue = result.issues[0]
assert hasattr(issue, "severity")
assert hasattr(issue, "pass_number")
assert hasattr(issue, "location")
assert hasattr(issue, "message")
assert hasattr(issue, "suggestion")
def test_issue_severity_levels(self):
"""Test that severity levels are valid."""
valid_severities = {"error", "warning", "info"}
issue = ValidationIssue(
severity="warning",
pass_number=1,
location="test.md",
message="Test issue",
)
assert issue.severity in valid_severities
def test_issue_pass_numbers(self):
"""Test that pass numbers are 1, 2, or 3."""
for pass_num in [1, 2, 3]:
issue = ValidationIssue(
severity="info",
pass_number=pass_num,
location="test.md",
message="Test issue",
)
assert issue.pass_number == pass_num
Documentation Writing - Complete Reference
This file contains the complete specification for documentation writing including frontmatter, Diataxis definitions, and style conventions.
YAML Frontmatter Specification
All substantial documentation files should include frontmatter:
---
title: Document Title
description: One-sentence summary for search and discovery
last_updated: 2025-11-25
review_schedule: quarterly | monthly | as-needed
owner: team-name | username
doc_type: tutorial | howto | reference | explanation
---Required Fields
| Field | Purpose | Example |
|---|---|---|
title | Document title | "Authentication Setup Guide" |
description | Search-friendly summary | "Configure JWT auth for the API" |
Optional Fields
| Field | Purpose | Example |
|---|---|---|
last_updated | Currency tracking | "2025-11-25" |
review_schedule | Maintenance schedule | "quarterly" |
owner | Responsible party | "platform-team" |
doc_type | Diataxis classification | "howto" |
prerequisites | Required reading | "[getting-started.md]" |
related | Cross-references | "[auth-config.md, tokens.md]" |
Diataxis Framework - Complete Definitions
Tutorials (Learning-Oriented)
Purpose: Take beginners through a complete learning experience.
Characteristics:
- Step-by-step progression
- Hands-on, doing-focused
- Minimal explanation (just enough to proceed)
- Clear success criteria at each step
- Building toward a complete outcome
User mindset: "I want to learn"
Writing style:
- Use "we" to include the reader
- Number each step clearly
- Include checkpoints ("You should now see...")
- Don't explain why, just show how
Example structure:
````markdown
Tutorial: Building Your First Agent
What You'll Build
A simple agent that responds to greetings.
Prerequisites
- Python 3.10+
- API key configured
Step 1: Create the Project
Create a new directory...
Step 2: Write the Agent Code
# ... complete, runnable code````
Step 3: Test Your Agent
Run: python agent.py You should see: "Agent ready"
Next Steps
Try adding tools to your agent.
````
How-To Guides (Task-Oriented)
Purpose: Help experienced users accomplish a specific goal.
Characteristics:
- Addresses a real-world task
- Assumes existing competence
- Focused on the goal, not learning
- Practical and actionable
- Multiple paths possible
User mindset: "I need to do X"
Writing style:
- Direct, imperative tone
- Focus on the task, not background
- Include common variations
- Address likely complications
Example structure:
# How to Deploy to Azure
This guide covers deploying amplihack to Azure Container Apps.
## Prerequisites
- Azure CLI installed
- Container registry configured
## Steps
### 1. Build the Containerdocker build -t amplihack:latest . ````
2. Push to Registry
az acr login --name myregistry
docker push myregistry.azurecr.io/amplihack:latest3. Deploy to Container Apps
az containerapp create \
--name amplihack \
--image myregistry.azurecr.io/amplihack:latestTroubleshooting
Container fails to start
Check logs: az containerapp logs show --name amplihack
See Also
- Azure configuration reference
````
Reference (Information-Oriented)
Purpose: Provide accurate, complete technical information.
Characteristics:
- Organized for lookup, not reading
- Complete and accurate
- Consistent structure
- No opinions or recommendations
- Austere and factual
User mindset: "I need to know the details"
Writing style:
- Neutral, descriptive tone
- Consistent formatting throughout
- Tables for structured data
- Complete parameter lists
Example structure:
# API Reference: /api/v1/analyze
## Endpoint
`POST /api/v1/analyze`
## Request
### Headers
| Header | Required | Description |
|--------|----------|-------------|
| `Authorization` | Yes | Bearer token |
| `Content-Type` | Yes | `application/json` |
### Body
{ "file_path": "string (required)", "options": { "depth": "integer (1-10, default: 3)" } } ````
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
file_path | string | Yes | - | Path to analyze |
options.depth | integer | No | 3 | Analysis depth |
Response
Success (200)
{
"complexity": 12.5,
"issues": []
}Errors
| Code | Meaning |
|---|---|
| 400 | Invalid request body |
| 404 | File not found |
| 500 | Internal error |
````
Explanation (Understanding-Oriented)
Purpose: Help readers understand concepts and context.
Characteristics:
- Provides background and context
- Explains "why" not "how"
- Connects concepts together
- Can include history and rationale
- Supports deeper understanding
User mindset: "I want to understand"
Writing style:
- Reflective, analytical tone
- Make connections explicit
- Use analogies and comparisons
- Discuss trade-offs and alternatives
Example structure:
# Understanding the Brick Philosophy
## Why Modularity Matters
Traditional software development often leads to tightly coupled code...
## The LEGO Analogy
Like LEGO bricks, our modules have standardized connection points...
## Comparison with Other Approaches
| Approach | Pros | Cons |
|----------|------|------|
| Monolith | Simple start | Hard to maintain |
| Microservices | Independent | Complex infrastructure |
| Brick Philosophy | Balanced | Requires discipline |
## Historical Context
This philosophy emerged from observing AI-assisted development patterns...
## Trade-offs
Choosing the brick philosophy means accepting:
- Stricter module boundaries
- More upfront design effort
- But: easier regeneration and maintenance
## Related Concepts
- [Zero-BS Implementation](./zero-bs.md)
- [Regeneratable Code](./regeneratable.md)Markdown Style Conventions
Headings
# Title (H1) - One per document
## Major Section (H2) - Primary divisions
### Subsection (H3) - Secondary divisions
#### Detail (H4) - Rarely neededCode Blocks
Always specify the language:
````markdown
def example():
pass````
````
Include expected output:
print("Hello")
Output: Hello
````
````
Links
Internal links use relative paths:
See [authentication config](./auth-config.md)External links include context:
Based on [Anthropic's Agent SDK](https://docs.anthropic.com/agent-sdk)Admonitions
> **Note**: Important information
> **Warning**: Potential issues
> **Tip**: Helpful suggestionsDocumentation Review Checklist
Before Writing
- [ ] Identified document type (Diataxis)
- [ ] Chosen correct location in
docs/ - [ ] Reviewed existing related docs
- [ ] Identified linking parent document
During Writing
- [ ] Using plain, simple language
- [ ] Each section has a clear purpose
- [ ] Examples are real and runnable
- [ ] Headings are descriptive
- [ ] No temporal information included
Before Submitting
- [ ] File is in
docs/directory - [ ] Linked from
docs/index.mdor parent - [ ] Frontmatter included (for substantial docs)
- [ ] All code examples tested
- [ ] Spelling and grammar checked
- [ ] Relative links working
After Submitting
- [ ] Verify links work in rendered view
- [ ] Check table of contents renders correctly
- [ ] Confirm search finds the document
Token Budget Considerations
When writing documentation:
- Keep individual docs under 300 lines for best readability
- Split large docs into multiple files by topic
- Use links to reference related content
- Avoid duplicating information across docs
- Progressive disclosure: overview → details
Common Mistakes to Avoid
| Mistake | Problem | Solution |
|---|---|---|
| Mixing doc types | Confuses readers | One type per file |
| Generic examples | Not helpful | Use real project code |
| Missing context | Orphan links | Add descriptive text |
| Outdated content | Misleading | Review schedule |
| Deep nesting | Hard to navigate | Flatten structure |
| Too much detail | Overwhelming | Progressive disclosure |
Related skills
FAQ
What documentation types does documentation-writing cover?
documentation-writing covers READMEs, API references, runbooks, and contributor guides for amplihack. The skill keeps setup instructions, contracts, and operational steps documented as code changes.
When should teams invoke documentation-writing for amplihack?
Teams should invoke documentation-writing when amplihack setup, API contracts, or operational steps change and published docs risk drifting from the repository. The skill targets accurate maintainer-facing documentation.