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 path is empty or /, docsfy serves index.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 provider and model path segments in scripts/clients (the UI already does this with urlencode).

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: -OJ tells curl to use the server-provided filename from Content-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.