View and Download Generated Docs
docsfy exposes four read/download endpoints for generated documentation:
| Use case | Route | Resolution logic |
|---|---|---|
| View a specific variant | /docs/{project}/{provider}/{model}/{path:path} |
Uses the exact project/provider/model variant |
| View latest ready variant | /docs/{project}/{path:path} |
Picks the most recently generated ready variant |
| Download a specific variant | /api/projects/{name}/{provider}/{model}/download |
Streams a .tar.gz for the exact variant |
| Download latest ready variant | /api/projects/{name}/download |
Streams a .tar.gz for the latest ready variant |
Note: If
pathis empty or/, docsfy servesindex.html.
Variant-specific docs route
Use this when you want deterministic docs for one provider/model pair.
```1379:1403:src/docsfy/main.py @app.get("/docs/{project}/{provider}/{model}/{path:path}") async def serve_variant_docs( request: Request, project: str, provider: str, model: str, path: str = "index.html", ) -> FileResponse: if not path or path == "/": path = "index.html" project = _validate_project_name(project) proj = await _resolve_project( request, project, ai_provider=provider, ai_model=model ) # ... if not file_path.exists() or not file_path.is_file(): raise HTTPException(status_code=404, detail="File not found") return FileResponse(file_path)
Examples:
- `/docs/test-repo/claude/opus/`
- `/docs/test-repo/claude/opus/index.html`
- `/docs/test-repo/claude/opus/introduction.html`
The dashboard uses this route for **View Docs**:
```1481:1485:src/docsfy/templates/dashboard.html
{% if variant.status == 'ready' %}
<div class="variant-actions">
<a href="#" repo_name }}/{{ variant.ai_provider | urlencode }}/{{ variant.ai_model | urlencode }}/" target="_blank" class="btn btn-primary btn-sm">View Docs</a>
<a href="#" repo_name }}/{{ variant.ai_provider | urlencode }}/{{ variant.ai_model | urlencode }}/download" class="btn btn-secondary btn-sm">Download</a>
Tip: URL-encode
providerandmodelpath segments in scripts/clients (the UI already does this withurlencode).
Latest-variant docs route
Use this when you want “the newest ready docs” without specifying provider/model.
```1406:1420:src/docsfy/main.py @app.get("/docs/{project}/{path:path}") async def serve_docs( request: Request, project: str, path: str = "index.html" ) -> FileResponse: """Serve the most recently generated variant.""" if not path or path == "/": path = "index.html" project = _validate_project_name(project) if request.state.is_admin: latest = await get_latest_variant(project) else: latest = await get_latest_variant(project, owner=request.state.username) if not latest: raise HTTPException(status_code=404, detail="No docs available")
“Latest” is defined in storage as `status = 'ready'` ordered by `last_generated DESC`:
```552:566:src/docsfy/storage.py
async def get_latest_variant(
name: str, owner: str | None = None
) -> dict[str, str | int | None] | None:
"""Get the most recently generated ready variant for a repo."""
# ...
cursor = await db.execute(
"SELECT * FROM projects WHERE name = ? AND status = 'ready' ORDER BY last_generated DESC LIMIT 1",
(name,),
)
Warning: For non-admin users, latest routes are owner-scoped (
owner=request.state.username). If a project is shared with you by access grant, use the variant-specific route instead.
Download .tar.gz archives
Download a specific variant
```1074:1112:src/docsfy/main.py @app.get("/api/projects/{name}/{provider}/{model}/download") async def download_variant( request: Request, name: str, provider: str, model: str, ) -> StreamingResponse: # ... if project["status"] != "ready": raise HTTPException(status_code=400, detail="Variant not ready") # ... with tarfile.open(tar_path, mode="w:gz") as tar: tar.add(str(site_dir), arcname=f"{name}-{provider}-{model}") return StreamingResponse( _stream_and_cleanup(), media_type="application/gzip", headers={ "Content-Disposition": f'attachment; filename="{name}-{provider}-{model}-docs.tar.gz"' }, )
Behavior:
- Requires variant status `ready`
- Returns `Content-Type: application/gzip`
- Downloads as `{name}-{provider}-{model}-docs.tar.gz`
- Archive root directory is `{name}-{provider}-{model}/`
### Download latest ready variant
```1158:1194:src/docsfy/main.py
@app.get("/api/projects/{name}/download")
async def download_project(request: Request, name: str) -> StreamingResponse:
# ...
if request.state.is_admin:
latest = await get_latest_variant(name)
else:
latest = await get_latest_variant(name, owner=request.state.username)
if not latest:
raise HTTPException(status_code=404, detail=f"No ready variant for '{name}'")
# ...
with tarfile.open(tar_path, mode="w:gz") as tar:
tar.add(str(site_dir), arcname=name)
return StreamingResponse(
_stream_and_cleanup(),
media_type="application/gzip",
headers={"Content-Disposition": f'attachment; filename="{name}-docs.tar.gz"'},
)
Behavior:
- Picks latest ready variant
- Downloads as
{name}-docs.tar.gz - Archive root directory is
{name}/
CLI download examples
# Specific variant
curl -L -OJ \
-H "Authorization: Bearer ${DOCSFY_API_KEY}" \
"http://localhost:8000/api/projects/test-repo/claude/opus/download"
# Latest ready variant
curl -L -OJ \
-H "Authorization: Bearer ${DOCSFY_API_KEY}" \
"http://localhost:8000/api/projects/test-repo/download"
Tip:
-OJtellscurlto use the server-provided filename fromContent-Disposition.
What is inside the archive
Generated site content comes from render_site(), which writes static assets and pages into the variant site directory:
```243:290:src/docsfy/renderer.py index_html = render_index(project_name, tagline, navigation, repo_url=repo_url) (output_dir / "index.html").write_text(index_html, encoding="utf-8")
...
(output_dir / f"{slug}.html").write_text(page_html, encoding="utf-8") (output_dir / f"{slug}.md").write_text(md_content, encoding="utf-8")
...
(output_dir / "search-index.json").write_text( json.dumps(search_index), encoding="utf-8" )
Generate llms.txt files
llms_txt = _build_llms_txt(plan) (output_dir / "llms.txt").write_text(llms_txt, encoding="utf-8") llms_full_txt = _build_llms_full_txt(plan, valid_pages) (output_dir / "llms-full.txt").write_text(llms_full_txt, encoding="utf-8")
Typical archive contents include:
- `index.html`
- `*.html` rendered pages
- `*.md` source markdown pages
- `search-index.json`
- `llms.txt` and `llms-full.txt`
- `assets/*` static CSS/JS
- `.nojekyll`
## Auth, access, and error behavior
API routes return `401` when unauthenticated; browser routes redirect to `/login`:
```151:155:src/docsfy/main.py
if not user and not is_admin:
# Not authenticated
if request.url.path.startswith("/api/"):
return JSONResponse(status_code=401, content={"detail": "Unauthorized"})
return RedirectResponse(url="/login", status_code=302)
Project names are validated before route resolution:
```73:77:src/docsfy/main.py def validate_project_name(name: str) -> str: """Validate project name to prevent path traversal.""" if not _re.match(r"^[a-zA-Z0-9][a-zA-Z0-9.-]*$", name): raise HTTPException(status_code=400, detail=f"Invalid project name: '{name}'")
Common responses:
- `400`:
- variant download when status is not ready (`"Variant not ready"`)
- invalid project name
- `401`:
- unauthenticated API requests
- `403`:
- denied path traversal attempt (`"Access denied"`)
- `404`:
- docs file missing
- no latest ready docs (`"No docs available"`)
- no ready variant for latest download
- `409`:
- admin ambiguity when multiple owners have same `project/provider/model`
## Runtime configuration relevant to these routes
Default container mapping serves docsfy on port `8000`:
```1:10:docker-compose.yaml
services:
docsfy:
build: .
ports:
- "8000:8000"
env_file: .env
volumes:
- ./data:/data
Cookie/security settings are environment-driven:
```1:8:.env.example
REQUIRED - Admin key for user management (minimum 16 characters)
ADMIN_KEY=your-secure-admin-key-here-min-16-chars AI_PROVIDER=claude
[1m] = 1 million token context window, this is a valid model identifier
AI_MODEL=claude-opus-4-6[1m] AI_CLI_TIMEOUT=60
```27:28:.env.example
# Set to false for local HTTP development
# SECURE_COOKIES=false
Note: Models such as
claude-opus-4-6[1m]contain characters that should be URL-encoded when used in path segments.
Validation and test automation
The integration test explicitly validates all four routes:
```124:146:tests/test_integration.py response = await client.get("/docs/test-repo/claude/opus/index.html") assert response.status_code == 200
...
response = await client.get("/docs/test-repo/index.html") assert response.status_code == 200
...
response = await client.get("/api/projects/test-repo/claude/opus/download") assert response.status_code == 200 assert response.headers["content-type"] == "application/gzip"
...
response = await client.get("/api/projects/test-repo/download") assert response.status_code == 200 assert response.headers["content-type"] == "application/gzip"
Repository test entrypoint:
```5:7:tox.toml
[env.unittests]
deps = ["uv"]
commands = [["uv", "run", "--extra", "dev", "pytest", "-n", "auto", "tests"]]
Note: No checked-in GitHub Actions or other CI workflow manifests are present; test automation is defined via
tox.toml.