Showing preview only (255K chars total). Download the full file or copy to clipboard to get everything.
Repository: ahmedkhaleel2004/gitdiagram
Branch: main
Commit: 1e08d22dfd3b
Files: 112
Total size: 230.4 KB
Directory structure:
gitextract_484_xfnt/
├── .github/
│ ├── FUNDING.yml
│ └── workflows/
│ ├── ci.yml
│ └── deploy.yml
├── .gitignore
├── .nvmrc
├── CLAUDE.md
├── LICENSE
├── README.md
├── backend/
│ ├── .python-version
│ ├── Dockerfile
│ ├── app/
│ │ ├── __init__.py
│ │ ├── core/
│ │ │ ├── errors.py
│ │ │ └── observability.py
│ │ ├── main.py
│ │ ├── prompts.py
│ │ ├── routers/
│ │ │ └── generate.py
│ │ ├── services/
│ │ │ ├── github_service.py
│ │ │ ├── mermaid_service.py
│ │ │ ├── model_config.py
│ │ │ ├── openai_service.py
│ │ │ └── pricing.py
│ │ └── utils/
│ │ └── format_message.py
│ ├── deploy.sh
│ ├── entrypoint.sh
│ ├── nginx/
│ │ ├── api.conf
│ │ └── setup_nginx.sh
│ ├── package.json
│ ├── pyproject.toml
│ ├── scripts/
│ │ └── validate_mermaid.mjs
│ └── tests/
│ ├── conftest.py
│ ├── test_generate_router.py
│ ├── test_generate_utils.py
│ └── test_pricing.py
├── components.json
├── docker-compose.yml
├── docs/
│ ├── dev-setup.md
│ └── railway-backend.md
├── drizzle.config.ts
├── eslint.config.mjs
├── next.config.js
├── package.json
├── postcss.config.js
├── prettier.config.js
├── src/
│ ├── app/
│ │ ├── [username]/
│ │ │ └── [repo]/
│ │ │ ├── page.tsx
│ │ │ └── repo-page-client.tsx
│ │ ├── _actions/
│ │ │ ├── cache.ts
│ │ │ └── repo.ts
│ │ ├── api/
│ │ │ ├── generate/
│ │ │ │ ├── cost/
│ │ │ │ │ └── route.ts
│ │ │ │ └── stream/
│ │ │ │ └── route.ts
│ │ │ └── healthz/
│ │ │ └── route.ts
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── providers.tsx
│ ├── components/
│ │ ├── action-button.tsx
│ │ ├── api-key-button.tsx
│ │ ├── api-key-dialog.tsx
│ │ ├── copy-button.tsx
│ │ ├── export-dropdown.tsx
│ │ ├── footer.tsx
│ │ ├── header-client.tsx
│ │ ├── header.tsx
│ │ ├── hero.tsx
│ │ ├── loading-animation.tsx
│ │ ├── loading.tsx
│ │ ├── main-card.tsx
│ │ ├── mermaid-diagram.test.tsx
│ │ ├── mermaid-diagram.tsx
│ │ ├── private-repos-dialog.tsx
│ │ ├── theme-toggle.tsx
│ │ └── ui/
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── dialog.tsx
│ │ ├── input.tsx
│ │ ├── progress.tsx
│ │ ├── sonner.tsx
│ │ ├── switch.tsx
│ │ ├── textarea.tsx
│ │ └── tooltip.tsx
│ ├── env.js
│ ├── features/
│ │ └── diagram/
│ │ ├── api.ts
│ │ ├── export.ts
│ │ ├── github-url.test.ts
│ │ ├── github-url.ts
│ │ ├── sse.test.ts
│ │ ├── sse.ts
│ │ └── types.ts
│ ├── hooks/
│ │ ├── diagram/
│ │ │ ├── useDiagramExport.ts
│ │ │ ├── useDiagramStream.test.ts
│ │ │ └── useDiagramStream.ts
│ │ ├── useDiagram.ts
│ │ └── useStarReminder.tsx
│ ├── lib/
│ │ ├── exampleRepos.ts
│ │ └── utils.ts
│ ├── server/
│ │ ├── db/
│ │ │ ├── index.ts
│ │ │ └── schema.ts
│ │ ├── generate/
│ │ │ ├── format.ts
│ │ │ ├── github.ts
│ │ │ ├── mermaid.test.ts
│ │ │ ├── mermaid.ts
│ │ │ ├── model-config.ts
│ │ │ ├── openai.ts
│ │ │ ├── pricing.test.ts
│ │ │ ├── pricing.ts
│ │ │ ├── prompts.ts
│ │ │ └── types.ts
│ │ └── github-stars.ts
│ └── styles/
│ └── globals.css
├── start-database.sh
├── tailwind.config.ts
├── tsconfig.json
├── vitest.config.ts
└── vitest.setup.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: ahmedkhaleel2004
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
pull_request:
push:
branches:
- main
jobs:
frontend:
runs-on: ubuntu-latest
env:
POSTGRES_URL: postgresql://postgres:password@localhost:5432/gitdiagram
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.30.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Typecheck
run: pnpm typecheck
- name: Frontend tests
run: pnpm test
- name: Build
run: pnpm build
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
run: pip install uv==0.5.24
- name: Sync backend dependencies
run: cd backend && uv sync --frozen --no-install-project
- name: Import sanity
run: cd backend && uv run python -m compileall app
- name: Backend tests
run: cd backend && uv run pytest -q
================================================
FILE: .github/workflows/deploy.yml
================================================
name: Deploy to EC2
on:
# Disabled for automatic deploys after migrating to Next.js/Vercel backend.
# Kept as legacy workflow for historical reference / manual fallback use.
workflow_dispatch:
inputs:
confirm_legacy_ec2_deploy:
description: "Type true to run legacy EC2 deploy"
required: false
default: "false"
jobs:
deploy:
if: ${{ github.event.inputs.confirm_legacy_ec2_deploy == 'true' }}
runs-on: ubuntu-latest
# Add concurrency to prevent multiple deployments running at once
concurrency:
group: production
cancel-in-progress: true
steps:
- uses: actions/checkout@v4
- name: Deploy to EC2
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ secrets.EC2_HOST }}
username: ubuntu
key: ${{ secrets.EC2_SSH_KEY }}
script: |
cd ~/gitdiagram
git fetch origin main
git checkout main
git pull --ff-only origin main
sudo chmod +x ./backend/nginx/setup_nginx.sh
sudo ./backend/nginx/setup_nginx.sh
chmod +x ./backend/deploy.sh
./backend/deploy.sh
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# database
/prisma/db.sqlite
/prisma/db.sqlite-journal
db.sqlite
# next.js
/.next/
/out/
next-env.d.ts
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
.env
.env*.local
.env-e
# vercel
.vercel
# typescript
*.tsbuildinfo
# idea files
.idea
__pycache__/
venv
backend/.venv
.venv
# vscode
.vscode/
================================================
FILE: .nvmrc
================================================
22
================================================
FILE: CLAUDE.md
================================================
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
GitDiagram converts GitHub repositories into interactive Mermaid diagrams using a 3-stage LLM pipeline. It's a full-stack app with a Next.js frontend (Vercel) and FastAPI backend (Railway).
## Commands
### Frontend (pnpm, Node 22)
```bash
pnpm install # Install dependencies
pnpm dev # Start Next.js dev server (Turbo)
pnpm build # Production build
pnpm lint # ESLint
pnpm check # Type-check + lint
pnpm test # Vitest (frontend unit tests)
pnpm format:write # Prettier formatting
```
### Backend (Python 3.12, uv)
```bash
cd backend
uv sync --no-install-project # Install pinned deps into .venv
uv run pytest -q # Run all backend tests
uv run pytest tests/path/test_file.py::test_name # Run single test
uv run python -m compileall app # Compile check
```
### Database
```bash
pnpm db:push # Push schema changes to Postgres
pnpm db:generate # Generate Drizzle migration files
pnpm db:studio # Open Drizzle Studio
```
### Local Development
```bash
# Start local Postgres
./start-database.sh
# Start FastAPI backend (Docker, recommended for production parity)
docker-compose up --build -d
docker-compose logs -f api
# OR start FastAPI backend directly
pnpm dev:backend # runs uvicorn via uv
```
To route the Next.js frontend to a local FastAPI backend, set in `.env`:
```
NEXT_PUBLIC_USE_LEGACY_BACKEND=true
NEXT_PUBLIC_API_DEV_URL=http://localhost:8000
```
## Architecture
### Dual-Backend Design
The app supports two generation backends controlled by `NEXT_PUBLIC_USE_LEGACY_BACKEND`:
- **FastAPI** (`backend/`) on Railway — primary production path
- **Next.js Route Handlers** (`src/app/api/generate/`) — legacy fallback
Both expose the same SSE streaming API. The frontend (`src/features/diagram/api.ts`) routes to one or the other transparently.
### 3-Stage LLM Pipeline
Diagram generation uses three sequential OpenAI streaming calls:
1. **Explanation** — understands the repo structure
2. **Component Mapping** — maps components to file paths (XML tags extracted)
3. **Mermaid Diagram** — generates Mermaid syntax with click events
After stage 3, Mermaid syntax is validated (via `backend/scripts/validate_mermaid.mjs` or `src/server/generate/mermaid.ts`) and auto-fixed for up to 3 attempts if invalid. Prompts live in `backend/app/prompts.py` and `src/server/generate/prompts.ts`.
### Streaming State Machine
SSE events flow through states: `idle → started → explanation_* → mapping_* → diagram_* → diagram_fix_* → complete`
Frontend: `src/hooks/diagram/useDiagramStream.ts` manages state.
Backend: `backend/app/routers/generate.py` emits events.
### GitHub Authentication Priority
1. User-supplied PAT (from localStorage)
2. `GITHUB_PAT` env var
3. GitHub App (CLIENT_ID + PRIVATE_KEY + INSTALLATION_ID)
### Caching
Generated diagrams are cached in PostgreSQL (`gitdiagram_diagram_cache` table, schema at `src/server/db/schema.ts`) keyed by `(username, repo)`. Server action: `src/app/_actions/cache.ts`.
### Path Aliases
TypeScript uses `~/*` → `./src/*`.
## Key File Locations
| Concern | Frontend | Backend |
|---|---|---|
| Prompts | `src/server/generate/prompts.ts` | `backend/app/prompts.py` |
| GitHub client | `src/server/generate/github.ts` | `backend/app/services/github_service.py` |
| OpenAI streaming | `src/server/generate/openai.ts` | `backend/app/services/openai_service.py` |
| Mermaid validation | `src/server/generate/mermaid.ts` | `backend/app/services/mermaid_service.py` |
| Stream endpoint | `src/app/api/generate/stream/` | `backend/app/routers/generate.py` |
| DB schema | `src/server/db/schema.ts` | — |
| Frontend API client | `src/features/diagram/api.ts` | — |
| Main diagram hook | `src/hooks/useDiagram.ts` | — |
## Environment Variables
Minimum required (see `.env.example` for full list):
- `POSTGRES_URL` — Neon serverless Postgres
- `OPENAI_API_KEY` — used for all generation stages
- `GITHUB_PAT` — optional but avoids GitHub rate limits
- `OPENAI_MODEL` — single model for all three pipeline stages
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2024 Ahmed Khaleel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
[](https://gitdiagram.com/)

[](https://ko-fi.com/ahmedkhaleel2004)
# GitDiagram
Turn any GitHub repository into an interactive diagram for visualization in seconds.
You can also replace `hub` with `diagram` in any Github URL to access its diagram.
## 🚀 Features
- 👀 **Instant Visualization**: Convert any GitHub repository structure into a system design / architecture diagram
- 🎨 **Interactivity**: Click on components to navigate directly to source files and relevant directories
- ⚡ **Fast Generation**: Powered by OpenAI GPT-5.4 mini (configurable) for quick and accurate diagrams
- 🖼️ **Export Options**: Copy Mermaid code or download the generated diagram as PNG
- 🌐 **API Access**: Public API available for integration (WIP)
## ⚙️ Tech Stack
- **Frontend**: Next.js, TypeScript, Tailwind CSS, ShadCN
- **Backend**: FastAPI (Railway), with Next.js Route Handlers available as a fallback path
- **Database**: PostgreSQL (with Drizzle ORM)
- **AI**: OpenAI GPT-5.4 mini (via `OPENAI_MODEL`)
- **Deployment**: Vercel (frontend) + Railway (backend)
- **CI/CD**: GitHub Actions
- **Analytics**: PostHog, Api-Analytics
## 🔄 Backend Architecture Update
GitDiagram now runs its primary generation backend on FastAPI (deployed on Railway).
Frontend calls are routed to the external backend by setting:
- `NEXT_PUBLIC_USE_LEGACY_BACKEND=true`
- `NEXT_PUBLIC_API_DEV_URL=https://<your-railway-domain>`
The variable name contains "LEGACY" for backward compatibility, but it now points to the primary external backend in production.
## 🤔 About
I created this because I wanted to contribute to open-source projects but quickly realized their codebases are too massive for me to dig through manually, so this helps me get started - but it's definitely got many more use cases!
Given any public (or private!) GitHub repository it generates diagrams in Mermaid.js with OpenAI's GPT-5.4 mini! (Previously Claude 3.5 Sonnet)
I extract information from the file tree and README for details and interactivity (you can click components to be taken to relevant files and directories).
Most of what you might call the "processing" of this app is done with prompt engineering and a 3-step streaming pipeline in the FastAPI backend under `/backend`.
## 🔒 How to diagram private repositories
You can simply click on "Private Repos" in the header and follow the instructions by providing a GitHub personal access token with the `repo` scope.
You can also self-host this app locally (backend separated as well!) with the steps below.
## 🛠️ Self-hosting / Local Development
1. Clone the repository
```bash
git clone https://github.com/ahmedkhaleel2004/gitdiagram.git
cd gitdiagram
```
2. Install dependencies
```bash
pnpm i
```
3. Set up environment variables (create .env)
```bash
cp .env.example .env
```
Then edit the `.env` file with your OpenAI API key and optional GitHub personal access token.
4. Start local database
```bash
chmod +x start-database.sh
./start-database.sh
```
When prompted to generate a random password, input yes.
The Postgres database will start in a container at `localhost:5432`
5. Initialize the database schema
```bash
pnpm db:push
```
You can view and interact with the database using `pnpm db:studio`
6. Run frontend
```bash
pnpm dev
```
You can now access the website at `localhost:3000`.
Run FastAPI backend (recommended if you want parity with production):
```bash
docker-compose up --build -d
docker-compose logs -f api
```
To route frontend calls to the external backend, set:
- `NEXT_PUBLIC_USE_LEGACY_BACKEND=true`
- `NEXT_PUBLIC_API_DEV_URL=http://localhost:8000`
For a full machine setup guide (Node/Python/uv versions + verification), see `docs/dev-setup.md`.
Quick validation:
```bash
pnpm check
pnpm test
pnpm build
```
Railway backend docs: `docs/railway-backend.md`.
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## Acknowledgements
Shoutout to [Romain Courtois](https://github.com/cyclotruc)'s [Gitingest](https://gitingest.com/) for inspiration and styling
## 🤔 Future Steps
- Implement font-awesome icons in diagram
- Implement an embedded feature like star-history.com but for diagrams. The diagram could also be updated progressively as commits are made.
================================================
FILE: backend/.python-version
================================================
3.12
================================================
FILE: backend/Dockerfile
================================================
FROM node:22.12.0-slim AS node-runtime
FROM python:3.12-slim
WORKDIR /app
ENV ENVIRONMENT=production
ENV PORT=8000
COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node
COPY --from=node-runtime /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
# Install uv inside the image
COPY --from=ghcr.io/astral-sh/uv:0.5.24 /uv /uvx /bin/
# Copy dependency manifests first for better layer caching
COPY pyproject.toml uv.lock ./
COPY package.json ./
COPY package-lock.json ./
# Install pinned runtime dependencies from uv lockfile
RUN uv sync --frozen --no-dev --no-install-project
RUN npm ci --omit=dev
# Copy application code
COPY . .
RUN chmod +x /app/entrypoint.sh && \
sed -i 's/\r$//' /app/entrypoint.sh && \
ls -la /app/entrypoint.sh
EXPOSE 8000
CMD ["/bin/bash", "/app/entrypoint.sh"]
================================================
FILE: backend/app/__init__.py
================================================
================================================
FILE: backend/app/core/errors.py
================================================
from __future__ import annotations
def api_error(code: str, message: str, **extra):
payload = {
"ok": False,
"error": message,
"error_code": code,
}
payload.update(extra)
return payload
def api_success(**data):
payload = {"ok": True}
payload.update(data)
return payload
================================================
FILE: backend/app/core/observability.py
================================================
from __future__ import annotations
import json
import logging
import time
logger = logging.getLogger("gitdiagram.api")
if not logger.handlers:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
def log_event(event: str, **fields):
logger.info(
json.dumps(
{
"event": event,
**fields,
},
default=str,
)
)
class Timer:
def __init__(self):
self.start = time.perf_counter()
def elapsed_ms(self) -> int:
return int((time.perf_counter() - self.start) * 1000)
================================================
FILE: backend/app/main.py
================================================
import os
from api_analytics.fastapi import Analytics
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.errors import api_success
from app.core.observability import log_event
from app.routers import generate
app = FastAPI()
cors_origins = os.getenv("CORS_ORIGINS")
if cors_origins:
origins = [origin.strip() for origin in cors_origins.split(",") if origin.strip()]
else:
origins = [
"http://localhost:3000",
"https://gitdiagram.com",
"https://www.gitdiagram.com",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["*"],
)
api_analytics_key = os.getenv("API_ANALYTICS_KEY")
if api_analytics_key:
app.add_middleware(Analytics, api_key=api_analytics_key)
app.include_router(generate.router)
@app.get("/")
async def root():
return api_success(message="Hello from GitDiagram API!")
@app.get("/healthz")
async def healthz():
log_event("healthz.ok")
return api_success(status="ok")
================================================
FILE: backend/app/prompts.py
================================================
# This is our processing. This is where GitDiagram makes the magic happen
# There is a lot of DETAIL we need to extract from the repository to produce detailed and accurate diagrams
# I will immediately put out there that I'm trying to reduce costs. Theoretically, I could, for like 5x better accuracy, include most file content as well which would make for perfect diagrams, but thats too many tokens for my wallet, and would probably greatly increase generation time. (maybe a paid feature?)
# THE PROCESS:
# imagine it like this:
# def prompt1(file_tree, readme) -> explanation of diagram
# def prompt2(explanation, file_tree) -> maps relevant directories and files to parts of diagram for interactivity
# def prompt3(explanation, map) -> Mermaid.js code
# Note: Originally prompt1 and prompt2 were combined - but I tested it, and turns out mapping relevant dirs and files in one prompt along with generating detailed and accurate diagrams was difficult for Claude 3.5 Sonnet. It lost detail in the explanation and dedicated more "effort" to the mappings, so this is now its own prompt.
# This is my first take at prompt engineering so if you have any ideas on optimizations please make an issue on the GitHub!
SYSTEM_FIRST_PROMPT = """
You are tasked with explaining to a principal software engineer how to draw the best and most accurate system design diagram / architecture of a given project. This explanation should be tailored to the specific project's purpose and structure. To accomplish this, you will be provided with two key pieces of information:
1. The complete and entire file tree of the project including all directory and file names, which will be enclosed in <file_tree> tags in the users message.
2. The README file of the project, which will be enclosed in <readme> tags in the users message.
Analyze these components carefully, as they will provide crucial information about the project's structure and purpose. Follow these steps to create an explanation for the principal software engineer:
1. Identify the project type and purpose:
- Examine the file structure and README to determine if the project is a full-stack application, an open-source tool, a compiler, or another type of software imaginable.
- Look for key indicators in the README, such as project description, features, or use cases.
2. Analyze the file structure:
- Pay attention to top-level directories and their names (e.g., "frontend", "backend", "src", "lib", "tests").
- Identify patterns in the directory structure that might indicate architectural choices (e.g., MVC pattern, microservices).
- Note any configuration files, build scripts, or deployment-related files.
3. Examine the README for additional insights:
- Look for sections describing the architecture, dependencies, or technical stack.
- Check for any diagrams or explanations of the system's components.
4. Based on your analysis, explain how to create a system design diagram that accurately represents the project's architecture. Include the following points:
a. Identify the main components of the system (e.g., frontend, backend, database, building, external services).
b. Determine the relationships and interactions between these components.
c. Highlight any important architectural patterns or design principles used in the project.
d. Include relevant technologies, frameworks, or libraries that play a significant role in the system's architecture.
5. Provide guidelines for tailoring the diagram to the specific project type:
- For a full-stack application, emphasize the separation between frontend and backend, database interactions, and any API layers.
- For an open-source tool, focus on the core functionality, extensibility points, and how it integrates with other systems.
- For a compiler or language-related project, highlight the different stages of compilation or interpretation, and any intermediate representations.
6. Instruct the principal software engineer to include the following elements in the diagram:
- Clear labels for each component
- Directional arrows to show data flow or dependencies
- Color coding or shapes to distinguish between different types of components
7. NOTE: Emphasize the importance of being very detailed and capturing the essential architectural elements. Don't overthink it too much, simply separating the project into as many components as possible is best.
Present your explanation and instructions within <explanation> tags, ensuring that you tailor your advice to the specific project based on the provided file tree and README content.
"""
# - A legend explaining any symbols or abbreviations used
# ^ removed since it was making the diagrams very long
# just adding some clear separation between the prompts
# ************************************************************
# ************************************************************
SYSTEM_SECOND_PROMPT = """
You are tasked with mapping key components of a system design to their corresponding files and directories in a project's file structure. You will be provided with a detailed explanation of the system design/architecture and a file tree of the project.
First, carefully read the system design explanation which will be enclosed in <explanation> tags in the users message.
Then, examine the file tree of the project which will be enclosed in <file_tree> tags in the users message.
Your task is to analyze the system design explanation and identify key components, modules, or services mentioned. Then, try your best to map these components to what you believe could be their corresponding directories and files in the provided file tree.
Guidelines:
1. Focus on major components described in the system design.
2. Look for directories and files that clearly correspond to these components.
3. Include both directories and specific files when relevant.
4. If a component doesn't have a clear corresponding file or directory, simply dont include it in the map.
Now, provide your final answer in the following format:
<component_mapping>
1. [Component Name]: [File/Directory Path]
2. [Component Name]: [File/Directory Path]
[Continue for all identified components]
</component_mapping>
Remember to be as specific as possible in your mappings, only use what is given to you from the file tree, and to strictly follow the components mentioned in the explanation.
"""
# ❌ BELOW IS A REMOVED SECTION FROM THE ABOVE PROMPT USED FOR CLAUDE 3.5 SONNET
# Before providing your final answer, use the <scratchpad> to think through your process:
# 1. List the key components identified in the system design.
# 2. For each component, brainstorm potential corresponding directories or files.
# 3. Verify your mappings by double-checking the file tree.
# <scratchpad>
# [Your thought process here]
# </scratchpad>
# just adding some clear separation between the prompts
# ************************************************************
# ************************************************************
SYSTEM_THIRD_PROMPT = """
You are a principal software engineer tasked with creating a system design diagram using Mermaid.js based on a detailed explanation. Your goal is to accurately represent the architecture and design of the project as described in the explanation.
The detailed explanation of the design will be enclosed in <explanation> tags in the users message.
Also, sourced from the explanation, as a bonus, a few of the identified components have been mapped to their paths in the project file tree, whether it is a directory or file which will be enclosed in <component_mapping> tags in the users message.
To create the Mermaid.js diagram:
1. Carefully read and analyze the provided design explanation.
2. Identify the main components, services, and their relationships within the system.
3. Determine the appropriate Mermaid.js diagram type to use (e.g., flowchart, sequence diagram, class diagram, architecture, etc.) based on the nature of the system described.
4. Create the Mermaid.js code to represent the design, ensuring that:
a. All major components are included
b. Relationships between components are clearly shown
c. The diagram accurately reflects the architecture described in the explanation
d. The layout is logical and easy to understand
Guidelines for diagram components and relationships:
- Use appropriate shapes for different types of components (e.g., rectangles for services, cylinders for databases, etc.)
- Use clear and concise labels for each component
- Show the direction of data flow or dependencies using arrows
- Group related components together if applicable
- Include any important notes or annotations mentioned in the explanation
- Just follow the explanation. It will have everything you need.
IMPORTANT!!: Please orient and draw the diagram as vertically as possible. You must avoid long horizontal lists of nodes and sections!
You must include click events for components of the diagram that have been specified in the provided <component_mapping>:
- Do not try to include the full url. This will be processed by another program afterwards. All you need to do is include the path.
- For example:
- This is a correct click event: `click Example "app/example.js"`
- This is an incorrect click event: `click Example "https://github.com/username/repo/blob/main/app/example.js"`
- Do this for as many components as specified in the component mapping, include directories and files.
- If you believe the component contains files and is a directory, include the directory path.
- If you believe the component references a specific file, include the file path.
- Make sure to include the full path to the directory or file exactly as specified in the component mapping.
- It is very important that you do this for as many files as possible. The more the better.
- IMPORTANT: THESE PATHS ARE FOR CLICK EVENTS ONLY, these paths should not be included in the diagram's node's names. Only for the click events. Paths should not be seen by the user.
Your output should be valid Mermaid.js code that can be rendered into a diagram.
Do not include an init declaration such as `%%{init: {'key':'etc'}}%%`. This is handled externally. Just return the diagram code.
Your response must strictly be just the Mermaid.js code, without any additional text or explanations.
No code fence or markdown ticks needed, simply return the Mermaid.js code.
Ensure that your diagram adheres strictly to the given explanation, without adding or omitting any significant components or relationships.
For general direction, the provided example below is how you should structure your code:
```mermaid
flowchart TD
%% or graph TD, your choice
%% Global entities
A("Entity A"):::external
%% more...
%% Subgraphs and modules
subgraph "Layer A"
A1("Module A"):::example
%% more modules...
%% inner subgraphs if needed...
end
%% more subgraphs, modules, etc...
%% Connections
A -->|"relationship"| B
%% and a lot more...
%% Click Events
click A1 "example/example.js"
%% and a lot more...
%% Styles
classDef frontend %%...
%% and a lot more...
```
EXTREMELY Important notes on syntax!!! (PAY ATTENTION TO THIS):
- Make sure to add colour to the diagram!!! This is extremely critical.
- In Mermaid.js syntax, we cannot include special characters for nodes without being inside quotes! For example: `EX[/api/process (Backend)]:::api` and `API -->|calls Process()| Backend` are two examples of syntax errors. They should be `EX["/api/process (Backend)"]:::api` and `API -->|"calls Process()"| Backend` respectively. Notice the quotes. This is extremely important. Make sure to include quotes for any string that contains special characters.
- In Mermaid.js syntax, you cannot apply a class style directly within a subgraph declaration. For example: `subgraph "Frontend Layer":::frontend` is a syntax error. However, you can apply them to nodes within the subgraph. For example: `Example["Example Node"]:::frontend` is valid, and `class Example1,Example2 frontend` is valid.
- In Mermaid.js syntax, there cannot be spaces in the relationship label names. For example: `A -->| "example relationship" | B` is a syntax error. It should be `A -->|"example relationship"| B`
- In Mermaid.js syntax, you cannot give subgraphs an alias like nodes. For example: `subgraph A "Layer A"` is a syntax error. It should be `subgraph "Layer A"`
"""
# ^^^ note: ive generated a few diagrams now and claude still writes incorrect mermaid code sometimes. in the future, refer to those generated diagrams and add important instructions to the prompt above to avoid those mistakes. examples are best.
# e. A legend is included
# ^ removed since it was making the diagrams very long
SYSTEM_FIX_MERMAID_PROMPT = """
You are a Mermaid syntax repair specialist.
You will receive:
- <mermaid_code>...</mermaid_code>
- <parser_error>...</parser_error>
- <explanation>...</explanation>
- <component_mapping>...</component_mapping>
Task:
- Fix Mermaid syntax errors while preserving the original diagram meaning.
- Keep all click events that map to repository paths.
- Keep diagram mostly vertical.
- Return Mermaid code only.
Rules:
- No markdown code fences.
- No extra commentary.
- Ensure final output is syntactically valid Mermaid.
"""
================================================
FILE: backend/app/routers/generate.py
================================================
from __future__ import annotations
import asyncio
import json
import re
from typing import Any
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field, ValidationError
from app.core.observability import Timer, log_event
from app.prompts import (
SYSTEM_FIRST_PROMPT,
SYSTEM_FIX_MERMAID_PROMPT,
SYSTEM_SECOND_PROMPT,
SYSTEM_THIRD_PROMPT,
)
from app.services.github_service import GitHubService
from app.services.mermaid_service import format_validation_feedback, validate_mermaid_syntax
from app.services.model_config import get_model
from app.services.openai_service import OpenAIService
from app.services.pricing import estimate_text_token_cost_usd
router = APIRouter(prefix="/generate", tags=["OpenAI"])
openai_service = OpenAIService()
MAX_MERMAID_FIX_ATTEMPTS = 3
MULTI_STAGE_INPUT_MULTIPLIER = 2
INPUT_OVERHEAD_TOKENS = 3000
ESTIMATED_OUTPUT_TOKENS = 8000
class GenerateRequest(BaseModel):
username: str = Field(min_length=1)
repo: str = Field(min_length=1)
api_key: str | None = Field(default=None, min_length=1)
github_pat: str | None = Field(default=None, min_length=1)
def _sse_message(payload: dict[str, Any]) -> str:
return f"data: {json.dumps(payload)}\n\n"
def _strip_mermaid_code_fences(text: str) -> str:
return text.replace("```mermaid", "").replace("```", "").strip()
def _extract_component_mapping(response: str) -> str:
start_tag = "<component_mapping>"
end_tag = "</component_mapping>"
start_index = response.find(start_tag)
end_index = response.find(end_tag)
if start_index == -1 or end_index == -1:
return response
return response[start_index:end_index]
def process_click_events(diagram: str, username: str, repo: str, branch: str) -> str:
click_pattern = r'click ([^\s"]+)\s+"([^"]+)"'
def replace_path(match: re.Match[str]) -> str:
node_id = match.group(1)
trimmed_path = match.group(2).strip().strip("\"'")
is_file = "." in trimmed_path and not trimmed_path.endswith("/")
path_type = "blob" if is_file else "tree"
full_url = f"https://github.com/{username}/{repo}/{path_type}/{branch}/{trimmed_path}"
return f'click {node_id} "{full_url}"'
return re.sub(click_pattern, replace_path, diagram)
def _parse_request_payload(payload: Any) -> tuple[GenerateRequest | None, str | None]:
try:
parsed = GenerateRequest.model_validate(payload)
return parsed, None
except ValidationError:
return None, "Invalid request payload."
def _get_github_data(username: str, repo: str, github_pat: str | None):
github_service = GitHubService(pat=github_pat)
return github_service.get_github_data(username, repo)
async def _estimate_repo_input_tokens(
model: str,
file_tree: str,
readme: str,
api_key: str | None = None,
) -> int:
try:
return await openai_service.count_input_tokens(
model=model,
system_prompt=SYSTEM_FIRST_PROMPT,
data={
"file_tree": file_tree,
"readme": readme,
},
api_key=api_key,
reasoning_effort="medium",
)
except Exception:
return openai_service.estimate_tokens(f"{file_tree}\n{readme}")
@router.post("/cost")
async def get_generation_cost(request: Request):
timer = Timer()
try:
payload = await request.json()
parsed, error = _parse_request_payload(payload)
if not parsed:
return JSONResponse(
{
"ok": False,
"error": error,
"error_code": "VALIDATION_ERROR",
}
)
github_data = _get_github_data(parsed.username, parsed.repo, parsed.github_pat)
model = get_model()
base_input_tokens = await _estimate_repo_input_tokens(
model=model,
file_tree=github_data.file_tree,
readme=github_data.readme,
api_key=parsed.api_key,
)
estimated_input_tokens = (
base_input_tokens * MULTI_STAGE_INPUT_MULTIPLIER + INPUT_OVERHEAD_TOKENS
)
estimated_output_tokens = ESTIMATED_OUTPUT_TOKENS
cost_usd, pricing_model, pricing = estimate_text_token_cost_usd(
model=model,
input_tokens=estimated_input_tokens,
output_tokens=estimated_output_tokens,
)
response_payload = {
"ok": True,
"cost": f"${cost_usd:.2f} USD",
"model": model,
"pricing_model": pricing_model,
"estimated_input_tokens": estimated_input_tokens,
"estimated_output_tokens": estimated_output_tokens,
"pricing": {
"input_per_million_usd": pricing.input_per_million_usd,
"output_per_million_usd": pricing.output_per_million_usd,
},
}
log_event(
"generate.cost.success",
username=parsed.username,
repo=parsed.repo,
elapsed_ms=timer.elapsed_ms(),
model=model,
)
return JSONResponse(response_payload)
except Exception as exc:
log_event(
"generate.cost.failed",
elapsed_ms=timer.elapsed_ms(),
error=str(exc),
)
return JSONResponse(
{
"ok": False,
"error": str(exc) if isinstance(exc, Exception) else "Failed to estimate generation cost.",
"error_code": "COST_ESTIMATION_FAILED",
}
)
@router.post("/stream")
async def generate_stream(request: Request):
try:
payload = await request.json()
except Exception:
return JSONResponse(
{
"ok": False,
"error": "Invalid request payload.",
"error_code": "VALIDATION_ERROR",
},
status_code=400,
)
parsed, error = _parse_request_payload(payload)
if not parsed:
return JSONResponse(
{
"ok": False,
"error": error,
"error_code": "VALIDATION_ERROR",
},
status_code=400,
)
async def event_generator():
timer = Timer()
def send(payload: dict[str, Any]) -> str:
return _sse_message(payload)
try:
github_data = _get_github_data(parsed.username, parsed.repo, parsed.github_pat)
model = get_model()
token_count = await _estimate_repo_input_tokens(
model=model,
file_tree=github_data.file_tree,
readme=github_data.readme,
api_key=parsed.api_key,
)
yield send(
{
"status": "started",
"message": "Starting generation process...",
}
)
if token_count > 50000 and token_count < 195000 and not parsed.api_key:
yield send(
{
"status": "error",
"error": "File tree and README combined exceeds token limit (50,000). This repository is too large for free generation. Provide your own OpenAI API key to continue.",
"error_code": "API_KEY_REQUIRED",
}
)
return
if token_count > 195000:
yield send(
{
"status": "error",
"error": "Repository is too large (>195k tokens) for analysis. Try a smaller repo.",
"error_code": "TOKEN_LIMIT_EXCEEDED",
}
)
return
yield send(
{
"status": "explanation_sent",
"message": f"Sending explanation request to {model}...",
}
)
await asyncio.sleep(0.08)
yield send(
{
"status": "explanation",
"message": "Analyzing repository structure...",
}
)
explanation = ""
async for chunk in openai_service.stream_completion(
model=model,
system_prompt=SYSTEM_FIRST_PROMPT,
data={
"file_tree": github_data.file_tree,
"readme": github_data.readme,
},
api_key=parsed.api_key,
reasoning_effort="medium",
):
explanation += chunk
yield send({"status": "explanation_chunk", "chunk": chunk})
yield send(
{
"status": "mapping_sent",
"message": f"Sending component mapping request to {model}...",
}
)
await asyncio.sleep(0.08)
yield send(
{
"status": "mapping",
"message": "Creating component mapping...",
}
)
full_mapping_response = ""
async for chunk in openai_service.stream_completion(
model=model,
system_prompt=SYSTEM_SECOND_PROMPT,
data={
"explanation": explanation,
"file_tree": github_data.file_tree,
},
api_key=parsed.api_key,
reasoning_effort="low",
):
full_mapping_response += chunk
yield send({"status": "mapping_chunk", "chunk": chunk})
component_mapping = _extract_component_mapping(full_mapping_response)
yield send(
{
"status": "diagram_sent",
"message": f"Sending diagram generation request to {model}...",
}
)
await asyncio.sleep(0.08)
yield send(
{
"status": "diagram",
"message": "Generating diagram...",
}
)
mermaid_code = ""
async for chunk in openai_service.stream_completion(
model=model,
system_prompt=SYSTEM_THIRD_PROMPT,
data={
"explanation": explanation,
"component_mapping": component_mapping,
},
api_key=parsed.api_key,
reasoning_effort="low",
):
mermaid_code += chunk
yield send({"status": "diagram_chunk", "chunk": chunk})
candidate_diagram = _strip_mermaid_code_fences(mermaid_code)
validation_result = await asyncio.to_thread(
validate_mermaid_syntax,
candidate_diagram,
)
had_fix_loop = not validation_result.valid
if not validation_result.valid:
parser_feedback = format_validation_feedback(validation_result)
yield send(
{
"status": "diagram_fixing",
"message": "Diagram generated. Mermaid syntax validation failed, starting auto-fix loop...",
"parser_error": parser_feedback,
}
)
attempt = 1
while (not validation_result.valid) and attempt <= MAX_MERMAID_FIX_ATTEMPTS:
parser_feedback = format_validation_feedback(validation_result)
yield send(
{
"status": "diagram_fix_attempt",
"message": f"Fixing Mermaid syntax (attempt {attempt}/{MAX_MERMAID_FIX_ATTEMPTS})...",
"fix_attempt": attempt,
"fix_max_attempts": MAX_MERMAID_FIX_ATTEMPTS,
"parser_error": parser_feedback,
}
)
repaired_diagram = ""
async for chunk in openai_service.stream_completion(
model=model,
system_prompt=SYSTEM_FIX_MERMAID_PROMPT,
data={
"mermaid_code": candidate_diagram,
"parser_error": parser_feedback,
"explanation": explanation,
"component_mapping": component_mapping,
},
api_key=parsed.api_key,
reasoning_effort="low",
):
repaired_diagram += chunk
yield send(
{
"status": "diagram_fix_chunk",
"chunk": chunk,
"fix_attempt": attempt,
"fix_max_attempts": MAX_MERMAID_FIX_ATTEMPTS,
}
)
candidate_diagram = _strip_mermaid_code_fences(repaired_diagram)
yield send(
{
"status": "diagram_fix_validating",
"message": f"Validating Mermaid syntax after attempt {attempt}/{MAX_MERMAID_FIX_ATTEMPTS}...",
"fix_attempt": attempt,
"fix_max_attempts": MAX_MERMAID_FIX_ATTEMPTS,
}
)
validation_result = await asyncio.to_thread(
validate_mermaid_syntax,
candidate_diagram,
)
attempt += 1
if not validation_result.valid:
yield send(
{
"status": "error",
"error": "Generated Mermaid remained syntactically invalid after auto-fix attempts. Please retry generation.",
"error_code": "MERMAID_SYNTAX_UNRESOLVED",
"parser_error": format_validation_feedback(validation_result),
}
)
return
processed_diagram = process_click_events(
candidate_diagram,
parsed.username,
parsed.repo,
github_data.default_branch,
)
if had_fix_loop:
yield send(
{
"status": "diagram_fixing",
"message": "Mermaid syntax validated. Finalizing diagram output...",
}
)
yield send(
{
"status": "complete",
"diagram": processed_diagram,
"explanation": explanation,
"mapping": component_mapping,
}
)
log_event(
"generate.stream.success",
username=parsed.username,
repo=parsed.repo,
elapsed_ms=timer.elapsed_ms(),
model=model,
)
except Exception as exc:
yield send(
{
"status": "error",
"error": str(exc) if isinstance(exc, Exception) else "Streaming generation failed.",
"error_code": "STREAM_FAILED",
}
)
log_event(
"generate.stream.failed",
username=parsed.username,
repo=parsed.repo,
elapsed_ms=timer.elapsed_ms(),
error=str(exc),
)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
================================================
FILE: backend/app/services/github_service.py
================================================
from __future__ import annotations
import base64
import os
from datetime import UTC, datetime, timedelta
from dataclasses import dataclass
import jwt
import requests
EXCLUDED_PATTERNS = [
"node_modules/",
"vendor/",
"venv/",
".min.",
".pyc",
".pyo",
".pyd",
".so",
".dll",
".class",
".jpg",
".jpeg",
".png",
".gif",
".ico",
".svg",
".ttf",
".woff",
".webp",
"__pycache__/",
".cache/",
".tmp/",
"yarn.lock",
"poetry.lock",
"*.log",
".vscode/",
".idea/",
]
@dataclass(frozen=True)
class GithubData:
default_branch: str
file_tree: str
readme: str
def _should_include_file(path: str) -> bool:
lower_path = path.lower()
return not any(pattern in lower_path for pattern in EXCLUDED_PATTERNS)
def _fetch_json(url: str, headers: dict[str, str], not_found_message: str) -> dict:
response = requests.get(url, headers=headers, timeout=30)
if response.status_code == 404:
raise ValueError(not_found_message)
if not response.ok:
raise ValueError(f"GitHub request failed ({response.status_code}): {response.text}")
return response.json()
class GitHubService:
def __init__(self, pat: str | None = None):
# Request-provided PAT (or env PAT) has top priority.
self.github_token = (pat or os.getenv("GITHUB_PAT") or "").strip() or None
# GitHub App credentials are used when PAT is unavailable.
self.client_id = (os.getenv("GITHUB_CLIENT_ID") or "").strip() or None
self.private_key = (os.getenv("GITHUB_PRIVATE_KEY") or "").strip() or None
self.installation_id = (os.getenv("GITHUB_INSTALLATION_ID") or "").strip() or None
self.access_token: str | None = None
self.token_expires_at: datetime | None = None
def _normalize_private_key(self) -> str:
if not self.private_key:
raise ValueError("Missing GITHUB_PRIVATE_KEY.")
# Supports both literal newlines and escaped \\n forms.
return self.private_key.replace("\\n", "\n")
def _can_use_app_auth(self) -> bool:
return bool(self.client_id and self.private_key and self.installation_id)
def _generate_jwt(self) -> str:
if not self.client_id:
raise ValueError("Missing GITHUB_CLIENT_ID.")
now = int(datetime.now(UTC).timestamp())
payload = {
"iat": now,
"exp": now + (10 * 60),
"iss": self.client_id,
}
return jwt.encode(payload, self._normalize_private_key(), algorithm="RS256")
def _get_installation_token(self) -> str:
if self.access_token and self.token_expires_at and self.token_expires_at > datetime.now(UTC):
return self.access_token
if not self.installation_id:
raise ValueError("Missing GITHUB_INSTALLATION_ID.")
jwt_token = self._generate_jwt()
response = requests.post(
f"https://api.github.com/app/installations/{self.installation_id}/access_tokens",
headers={
"Authorization": f"Bearer {jwt_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
timeout=30,
)
if not response.ok:
raise ValueError(
f"GitHub app token request failed ({response.status_code}): {response.text}"
)
payload = response.json()
token = payload.get("token")
if not isinstance(token, str) or not token:
raise ValueError("GitHub app token response missing token.")
expires_at_raw = payload.get("expires_at")
if isinstance(expires_at_raw, str):
try:
expires_at = datetime.fromisoformat(expires_at_raw.replace("Z", "+00:00"))
except ValueError:
expires_at = datetime.now(UTC) + timedelta(minutes=50)
else:
expires_at = datetime.now(UTC) + timedelta(minutes=50)
self.access_token = token
self.token_expires_at = expires_at
return token
def _get_headers(self) -> dict[str, str]:
if self.github_token:
return {
"Authorization": f"token {self.github_token}",
"Accept": "application/vnd.github+json",
}
if self._can_use_app_auth():
token = self._get_installation_token()
return {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
return {"Accept": "application/vnd.github+json"}
def get_default_branch(self, username: str, repo: str) -> str:
data = _fetch_json(
f"https://api.github.com/repos/{username}/{repo}",
self._get_headers(),
"Repository not found.",
)
return data.get("default_branch") or "main"
def get_github_file_paths_as_list(self, username: str, repo: str, branch: str) -> str:
data = _fetch_json(
f"https://api.github.com/repos/{username}/{repo}/git/trees/{branch}?recursive=1",
self._get_headers(),
"Could not fetch repository file tree.",
)
paths = [
item.get("path")
for item in (data.get("tree") or [])
if isinstance(item.get("path"), str) and _should_include_file(item["path"])
]
if not paths:
raise ValueError(
"Could not fetch repository file tree. Repository might be empty or inaccessible."
)
return "\n".join(paths)
def get_github_readme(self, username: str, repo: str) -> str:
data = _fetch_json(
f"https://api.github.com/repos/{username}/{repo}/readme",
self._get_headers(),
"No README found for the specified repository.",
)
content = data.get("content")
if not isinstance(content, str) or not content:
raise ValueError("No README found for the specified repository.")
encoding = data.get("encoding")
if encoding == "base64":
return base64.b64decode(content).decode("utf-8")
return content
def get_github_data(self, username: str, repo: str) -> GithubData:
default_branch = self.get_default_branch(username, repo)
file_tree = self.get_github_file_paths_as_list(username, repo, default_branch)
readme = self.get_github_readme(username, repo)
return GithubData(
default_branch=default_branch,
file_tree=file_tree,
readme=readme,
)
================================================
FILE: backend/app/services/mermaid_service.py
================================================
from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass
@dataclass(frozen=True)
class MermaidValidationResult:
valid: bool
message: str | None = None
line: int | None = None
token: str | None = None
expected: list[str] | None = None
def normalize_parser_message(message: str | None) -> str:
if not message:
return "Mermaid syntax is invalid and could not be parsed."
if "sanitize is not a function" in message or "__TURBOPACK__imported__module" in message:
return "Mermaid parser runtime failed in server context (sanitizer issue)."
return message
def validate_mermaid_syntax(diagram: str) -> MermaidValidationResult:
try:
proc = subprocess.run(
["node", "scripts/validate_mermaid.mjs"],
input=diagram,
text=True,
capture_output=True,
check=False,
)
except Exception as exc:
return MermaidValidationResult(
valid=False,
message=normalize_parser_message(str(exc)),
)
if proc.returncode != 0:
message = proc.stderr.strip() or proc.stdout.strip() or "Mermaid validation failed."
return MermaidValidationResult(valid=False, message=normalize_parser_message(message))
try:
payload = json.loads(proc.stdout)
except json.JSONDecodeError:
return MermaidValidationResult(
valid=False,
message=normalize_parser_message("Mermaid validator returned invalid JSON."),
)
valid = bool(payload.get("valid"))
message = payload.get("message")
normalized_message = (
normalize_parser_message(message)
if not valid
else (message if isinstance(message, str) else None)
)
return MermaidValidationResult(
valid=valid,
message=normalized_message,
line=payload.get("line"),
token=payload.get("token"),
expected=payload.get("expected"),
)
def format_validation_feedback(result: MermaidValidationResult) -> str:
if result.valid:
return "No syntax errors found."
details = [f"message: {result.message or 'unknown parse error'}"]
if isinstance(result.line, int):
details.append(f"line: {result.line}")
if result.token:
details.append(f"token: {result.token}")
if result.expected:
details.append(f"expected: {', '.join(result.expected)}")
return "\n".join(details)
================================================
FILE: backend/app/services/model_config.py
================================================
from __future__ import annotations
import os
DEFAULT_MODEL = "gpt-5.4-mini"
def get_model() -> str:
model = os.getenv("OPENAI_MODEL", "").strip()
return model or DEFAULT_MODEL
================================================
FILE: backend/app/services/openai_service.py
================================================
from __future__ import annotations
from typing import AsyncGenerator, Literal
import math
import os
from dotenv import load_dotenv
from openai import AsyncOpenAI
from app.utils.format_message import format_user_message
load_dotenv()
ReasoningEffort = Literal["low", "medium", "high"]
class OpenAIService:
def __init__(self):
self.default_api_key = os.getenv("OPENAI_API_KEY")
def _resolve_api_key(self, override_api_key: str | None = None) -> str:
api_key = (override_api_key or self.default_api_key or "").strip()
if not api_key:
raise ValueError(
"Missing OpenAI API key. Set OPENAI_API_KEY or provide api_key in request."
)
return api_key
@staticmethod
def estimate_tokens(text: str) -> int:
# Mirrors Next.js fallback heuristic.
return math.ceil(len(text) / 4)
@staticmethod
def _build_input(system_prompt: str, user_prompt: str) -> list[dict]:
return [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
@staticmethod
def _create_client(api_key: str) -> AsyncOpenAI:
# Keep explicit config local to this service.
return AsyncOpenAI(
api_key=api_key,
max_retries=2,
timeout=600,
)
async def stream_completion(
self,
*,
model: str,
system_prompt: str,
data: dict[str, str | None],
api_key: str | None = None,
reasoning_effort: ReasoningEffort | None = None,
max_output_tokens: int | None = None,
) -> AsyncGenerator[str, None]:
user_prompt = format_user_message(data)
resolved_api_key = self._resolve_api_key(api_key)
payload: dict = {
"model": model,
"stream": True,
"input": self._build_input(system_prompt, user_prompt),
}
if reasoning_effort:
payload["reasoning"] = {"effort": reasoning_effort}
if max_output_tokens:
payload["max_output_tokens"] = max_output_tokens
client = self._create_client(resolved_api_key)
stream = await client.responses.create(**payload)
try:
async for event in stream:
if event.type == "response.output_text.delta":
delta = getattr(event, "delta", None)
if isinstance(delta, str) and delta:
yield delta
continue
if event.type == "error":
message = getattr(event, "message", None) or "OpenAI stream failed."
raise ValueError(str(message))
finally:
await stream.close()
await client.close()
async def count_input_tokens(
self,
*,
model: str,
system_prompt: str,
data: dict[str, str | None],
api_key: str | None = None,
reasoning_effort: ReasoningEffort | None = None,
) -> int:
user_prompt = format_user_message(data)
resolved_api_key = self._resolve_api_key(api_key)
payload: dict = {
"model": model,
"input": self._build_input(system_prompt, user_prompt),
}
if reasoning_effort:
payload["reasoning"] = {"effort": reasoning_effort}
client = self._create_client(resolved_api_key)
try:
response = await client.responses.input_tokens.count(**payload)
input_tokens = getattr(response, "input_tokens", None)
if not isinstance(input_tokens, int):
raise ValueError("OpenAI input token count returned invalid payload.")
return input_tokens
finally:
await client.close()
================================================
FILE: backend/app/services/pricing.py
================================================
from __future__ import annotations
from dataclasses import dataclass
DEFAULT_PRICING_MODEL = "gpt-5.4-mini"
@dataclass(frozen=True)
class ModelPricing:
input_per_million_usd: float
output_per_million_usd: float
MODEL_PRICING: dict[str, ModelPricing] = {
"gpt-5.4": ModelPricing(input_per_million_usd=2.5, output_per_million_usd=15.0),
"gpt-5.4-pro": ModelPricing(input_per_million_usd=30.0, output_per_million_usd=180.0),
"gpt-5.4-mini": ModelPricing(input_per_million_usd=0.75, output_per_million_usd=4.5),
"gpt-5.4-nano": ModelPricing(input_per_million_usd=0.2, output_per_million_usd=1.25),
"gpt-5.2": ModelPricing(input_per_million_usd=1.75, output_per_million_usd=14.0),
"gpt-5.2-chat-latest": ModelPricing(
input_per_million_usd=1.75,
output_per_million_usd=14.0,
),
"gpt-5.2-codex": ModelPricing(input_per_million_usd=1.75, output_per_million_usd=14.0),
"gpt-5.2-pro": ModelPricing(input_per_million_usd=21.0, output_per_million_usd=168.0),
"gpt-5.1": ModelPricing(input_per_million_usd=1.25, output_per_million_usd=10.0),
"gpt-5": ModelPricing(input_per_million_usd=1.25, output_per_million_usd=10.0),
"gpt-5-mini": ModelPricing(input_per_million_usd=0.25, output_per_million_usd=2.0),
"gpt-5-nano": ModelPricing(input_per_million_usd=0.05, output_per_million_usd=0.4),
"o4-mini": ModelPricing(input_per_million_usd=1.1, output_per_million_usd=4.4),
}
DEFAULT_PRICING = MODEL_PRICING[DEFAULT_PRICING_MODEL]
def _strip_date_snapshot_suffix(model: str) -> str:
import re
return re.sub(r"-\d{4}-\d{2}-\d{2}$", "", model, flags=re.IGNORECASE)
def resolve_pricing_model(model: str) -> str:
normalized = model.strip().lower()
if normalized in MODEL_PRICING:
return normalized
without_date = _strip_date_snapshot_suffix(normalized)
if without_date in MODEL_PRICING:
return without_date
if without_date.startswith("gpt-5.4-pro"):
return "gpt-5.4-pro"
if without_date.startswith("gpt-5.4-mini"):
return "gpt-5.4-mini"
if without_date.startswith("gpt-5.4-nano"):
return "gpt-5.4-nano"
if without_date.startswith("gpt-5.4"):
return "gpt-5.4"
if without_date.startswith("gpt-5.2-pro"):
return "gpt-5.2-pro"
if without_date.startswith("gpt-5.2-codex"):
return "gpt-5.2-codex"
if without_date.startswith("gpt-5.2-chat"):
return "gpt-5.2-chat-latest"
if without_date.startswith("gpt-5.2"):
return "gpt-5.2"
if without_date.startswith("gpt-5.1"):
return "gpt-5.1"
if without_date.startswith("gpt-5-mini"):
return "gpt-5-mini"
if without_date.startswith("gpt-5-nano"):
return "gpt-5-nano"
if without_date.startswith("gpt-5"):
return "gpt-5"
if without_date.startswith("o4-mini"):
return "o4-mini"
return DEFAULT_PRICING_MODEL
def estimate_text_token_cost_usd(
model: str,
input_tokens: int,
output_tokens: int,
) -> tuple[float, str, ModelPricing]:
pricing_model = resolve_pricing_model(model)
pricing = MODEL_PRICING.get(pricing_model, DEFAULT_PRICING)
input_cost = (max(input_tokens, 0) / 1_000_000) * pricing.input_per_million_usd
output_cost = (max(output_tokens, 0) / 1_000_000) * pricing.output_per_million_usd
return (input_cost + output_cost, pricing_model, pricing)
================================================
FILE: backend/app/utils/format_message.py
================================================
def format_user_message(data: dict[str, str | None]) -> str:
parts: list[str] = []
for key, value in data.items():
if isinstance(value, str):
parts.append(f"<{key}>\n{value}\n</{key}>")
return "\n".join(parts)
================================================
FILE: backend/deploy.sh
================================================
#!/bin/bash
# Exit on any error
set -e
# Navigate to project directory
cd ~/gitdiagram
# Pull latest changes
git pull --ff-only origin main
# Build and restart containers with production environment
docker-compose down
ENVIRONMENT=production docker-compose up --build -d
# Remove unused images
docker image prune -f
# Show logs only if --logs flag is passed
if [ "$1" == "--logs" ]; then
docker-compose logs -f
else
echo "Deployment complete! Run 'docker-compose logs -f' to view logs"
fi
================================================
FILE: backend/entrypoint.sh
================================================
#!/bin/bash
set -euo pipefail
ENVIRONMENT="${ENVIRONMENT:-production}"
HOST="${HOST:-0.0.0.0}"
PORT="${PORT:-8000}"
WEB_CONCURRENCY="${WEB_CONCURRENCY:-2}"
echo "Current ENVIRONMENT: ${ENVIRONMENT}"
echo "Binding to ${HOST}:${PORT}"
if [ "${ENVIRONMENT}" = "development" ]; then
echo "Starting in development mode with hot reload..."
exec uv run --no-dev uvicorn app.main:app --host "${HOST}" --port "${PORT}" --reload
fi
echo "Starting in production mode..."
exec uv run --no-dev uvicorn app.main:app \
--host "${HOST}" \
--port "${PORT}" \
--timeout-keep-alive 300 \
--workers "${WEB_CONCURRENCY}" \
--loop uvloop \
--http httptools
================================================
FILE: backend/nginx/api.conf
================================================
server {
server_name api.gitdiagram.com;
# Block requests with no valid Host header
if ($host !~ ^(api.gitdiagram.com)$) {
return 444;
}
# Strictly allow only GET, POST, and OPTIONS requests for the specified paths (defined in my fastapi app)
location ~ ^/(generate(/cost|/stream)?|healthz|)?$ {
if ($request_method !~ ^(GET|POST|OPTIONS)$) {
return 444;
}
proxy_pass http://127.0.0.1:8000;
include proxy_params;
proxy_redirect off;
# Disable buffering for SSE
proxy_buffering off;
proxy_cache off;
# Required headers for SSE
proxy_set_header Connection '';
proxy_http_version 1.1;
}
# Return 444 for everything else (no response, just close connection)
location / {
return 444;
# keep access log on
}
# Add timeout settings
proxy_connect_timeout 300;
proxy_send_timeout 300;
proxy_read_timeout 300;
send_timeout 300;
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/api.gitdiagram.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/api.gitdiagram.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = api.gitdiagram.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name api.gitdiagram.com;
return 404; # managed by Certbot
}
================================================
FILE: backend/nginx/setup_nginx.sh
================================================
#!/bin/bash
# Exit on any error
set -e
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "Please run as root or with sudo"
exit 1
fi
# Copy Nginx configuration
echo "Copying Nginx configuration..."
cp "$(dirname "$0")/api.conf" /etc/nginx/sites-available/api
ln -sf /etc/nginx/sites-available/api /etc/nginx/sites-enabled/
# Test Nginx configuration
echo "Testing Nginx configuration..."
nginx -t
# Reload Nginx
echo "Reloading Nginx..."
systemctl reload nginx
echo "Nginx configuration updated successfully!"
================================================
FILE: backend/package.json
================================================
{
"name": "gitdiagram-backend-mermaid-validator",
"private": true,
"type": "module",
"dependencies": {
"dompurify": "3.3.1",
"jsdom": "28.1.0",
"mermaid": "11.12.3"
}
}
================================================
FILE: backend/pyproject.toml
================================================
[project]
name = "gitdiagram-backend"
version = "0.1.0"
description = "FastAPI backend for GitDiagram"
requires-python = ">=3.12,<3.13"
dependencies = [
"aiohttp==3.13.3",
"api-analytics==1.2.7",
"fastapi==0.128.8",
"openai==2.21.0",
"PyJWT[crypto]==2.11.0",
"python-dotenv==1.2.1",
"requests==2.32.5",
"tiktoken==0.12.0",
"uvicorn[standard]==0.40.0",
]
[dependency-groups]
dev = [
"httpx==0.28.1",
"pytest==8.3.4",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.uv]
package = false
================================================
FILE: backend/scripts/validate_mermaid.mjs
================================================
import { createRequire } from "node:module";
import { stdin, stdout, stderr } from "node:process";
import DOMPurify from "dompurify";
const require = createRequire(import.meta.url);
let mermaidInstance = null;
let initialized = false;
let domPurifyPatched = false;
function ensureDomPurifyPatched() {
if (domPurifyPatched) return;
try {
const domPurify = DOMPurify;
if (typeof domPurify === "function" && typeof domPurify.sanitize !== "function") {
const { JSDOM } = require("jsdom");
const domWindow = new JSDOM("<!doctype html><html><body></body></html>").window;
const domPurifyInstance = domPurify(domWindow);
Object.assign(domPurify, domPurifyInstance);
}
} catch {
// Best effort patch.
} finally {
domPurifyPatched = true;
}
}
async function getMermaid() {
if (mermaidInstance) return mermaidInstance;
ensureDomPurifyPatched();
const mermaidModule = await import("mermaid");
mermaidInstance = mermaidModule.default;
return mermaidInstance;
}
async function ensureMermaidInitialized() {
const mermaid = await getMermaid();
if (initialized) return mermaid;
mermaid.initialize({
startOnLoad: false,
securityLevel: "loose",
});
initialized = true;
return mermaid;
}
async function readStdin() {
let data = "";
for await (const chunk of stdin) {
data += chunk;
}
return data;
}
function normalizeError(error) {
return {
valid: false,
message: error?.message || "Mermaid syntax is invalid and could not be parsed.",
line: error?.hash?.line,
token: error?.hash?.token,
expected: error?.hash?.expected,
};
}
async function main() {
try {
const diagram = (await readStdin()).toString();
const mermaid = await ensureMermaidInitialized();
await mermaid.parse(diagram);
stdout.write(JSON.stringify({ valid: true }));
} catch (error) {
stdout.write(JSON.stringify(normalizeError(error)));
}
}
main().catch((error) => {
stderr.write(String(error?.message || error));
process.exit(1);
});
================================================
FILE: backend/tests/conftest.py
================================================
from pathlib import Path
import sys
BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))
================================================
FILE: backend/tests/test_generate_router.py
================================================
import json
from types import SimpleNamespace
from fastapi.testclient import TestClient
from app.main import app
from app.routers import generate
from app.services.mermaid_service import MermaidValidationResult
client = TestClient(app)
def test_healthz_ok():
response = client.get("/healthz")
assert response.status_code == 200
assert response.json() == {"ok": True, "status": "ok"}
def test_generate_cost_success(monkeypatch):
monkeypatch.setattr(
generate,
"_get_github_data",
lambda username, repo, github_pat=None: SimpleNamespace(
default_branch="main",
file_tree="src/main.py",
readme="# readme",
),
)
monkeypatch.setattr(generate, "get_model", lambda: "gpt-5.4-mini")
async def fake_count_input_tokens(*, model, system_prompt, data, api_key=None, reasoning_effort=None):
return 100
monkeypatch.setattr(generate.openai_service, "count_input_tokens", fake_count_input_tokens)
response = client.post(
"/generate/cost",
json={"username": "acme", "repo": "demo"},
)
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert data["cost"].endswith("USD")
assert data["model"] == "gpt-5.4-mini"
assert data["pricing_model"] == "gpt-5.4-mini"
assert "estimated_input_tokens" in data
assert "estimated_output_tokens" in data
def test_generate_cost_error(monkeypatch):
def fail_github_data(username, repo, github_pat=None):
raise ValueError("repo not found")
monkeypatch.setattr(generate, "_get_github_data", fail_github_data)
response = client.post(
"/generate/cost",
json={"username": "acme", "repo": "missing"},
)
assert response.status_code == 200
data = response.json()
assert data["ok"] is False
assert data["error_code"] == "COST_ESTIMATION_FAILED"
def test_generate_stream_event_order_with_fix_loop(monkeypatch):
monkeypatch.setattr(
generate,
"_get_github_data",
lambda username, repo, github_pat=None: SimpleNamespace(
default_branch="main",
file_tree="src/main.py",
readme="# readme",
),
)
monkeypatch.setattr(generate, "get_model", lambda: "gpt-5.4-mini")
async def fake_estimate_repo_input_tokens(model, file_tree, readme, api_key=None):
return 1000
async def fake_stream_completion(*, model, system_prompt, data, api_key=None, reasoning_effort=None, max_output_tokens=None):
if "explaining to a principal" in system_prompt:
yield "<explanation>Repo explanation</explanation>"
return
if "mapping key components" in system_prompt:
yield "<component_mapping>"
yield "1. API: src/main.py"
yield "</component_mapping>"
return
if "syntax repair specialist" in system_prompt:
yield 'flowchart TD\nA["API"] --> B["Worker"]\nclick A "src/main.py"'
return
yield 'flowchart TD\nA["API"] --> B["Worker"]\nclick A "src/main.py"'
validation_results = iter(
[
MermaidValidationResult(valid=False, message="bad syntax"),
MermaidValidationResult(valid=True),
]
)
monkeypatch.setattr(generate, "_estimate_repo_input_tokens", fake_estimate_repo_input_tokens)
monkeypatch.setattr(generate.openai_service, "stream_completion", fake_stream_completion)
monkeypatch.setattr(generate, "validate_mermaid_syntax", lambda diagram: next(validation_results))
response = client.post(
"/generate/stream",
json={"username": "acme", "repo": "demo"},
)
assert response.status_code == 200
events = []
payloads = []
for block in response.text.split("\n\n"):
if not block.startswith("data: "):
continue
payload = json.loads(block[6:])
payloads.append(payload)
if "status" in payload:
events.append(payload["status"])
assert "started" in events
assert "explanation_sent" in events
assert "mapping_sent" in events
assert "diagram_sent" in events
assert "diagram_fixing" in events
assert "diagram_fix_attempt" in events
assert "diagram_fix_validating" in events
assert events[-1] == "complete"
complete_payload = payloads[-1]
assert complete_payload["status"] == "complete"
assert "https://github.com/acme/demo/blob/main/src/main.py" in complete_payload["diagram"]
def test_modify_route_removed():
response = client.post("/modify", json={})
assert response.status_code == 404
================================================
FILE: backend/tests/test_generate_utils.py
================================================
from app.routers.generate import process_click_events
def test_process_click_events_builds_blob_and_tree_links():
diagram = 'flowchart TD\nclick Api "src/api.ts"\nclick Core "src/core"'
output = process_click_events(diagram, "u", "r", "main")
assert 'click Api "https://github.com/u/r/blob/main/src/api.ts"' in output
assert 'click Core "https://github.com/u/r/tree/main/src/core"' in output
================================================
FILE: backend/tests/test_pricing.py
================================================
from app.services.pricing import estimate_text_token_cost_usd, resolve_pricing_model
def test_resolve_pricing_model_keeps_gpt_5_4_mini_on_its_own_tier():
assert resolve_pricing_model("gpt-5.4-mini") == "gpt-5.4-mini"
assert resolve_pricing_model("gpt-5.4-mini-2026-03-17") == "gpt-5.4-mini"
def test_estimate_text_token_cost_uses_gpt_5_4_mini_pricing():
cost_usd, pricing_model, pricing = estimate_text_token_cost_usd(
model="gpt-5.4-mini",
input_tokens=1_000_000,
output_tokens=1_000_000,
)
assert pricing_model == "gpt-5.4-mini"
assert pricing.input_per_million_usd == 0.75
assert pricing.output_per_million_usd == 4.5
assert cost_usd == 5.25
================================================
FILE: components.json
================================================
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "~/components",
"utils": "~/lib/utils",
"ui": "~/components/ui",
"lib": "~/lib",
"hooks": "~/hooks"
},
"iconLibrary": "lucide"
}
================================================
FILE: docker-compose.yml
================================================
services:
api:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8000:8000"
volumes:
- ./backend:/app
env_file:
- .env
environment:
- ENVIRONMENT=${ENVIRONMENT:-development} # Default to development if not set
restart: unless-stopped
================================================
FILE: docs/dev-setup.md
================================================
# Local Development Setup
This project runs generation primarily through the FastAPI backend in `backend/` (Railway in production).
Next.js Route Handlers under `/api/generate/*` remain available as an optional fallback path.
## 1) Install tool versions
Recommended versions:
- Node.js: `22.x` (see `.nvmrc`)
- pnpm: `9.13.0`
- Python: `3.12.x` (required for FastAPI backend work)
- uv: `0.5.24+` (required for FastAPI backend work)
- Docker: latest stable
Install/check:
```bash
node -v
pnpm -v
python3 --version
uv --version
docker --version
```
Expected:
- Node starts with `v22`
- pnpm prints `9.13.0` (or compatible in the same series)
- Python starts with `3.12`
## 2) Install frontend dependencies
```bash
pnpm install
```
## 3) Sync backend dependencies with uv
```bash
cd backend
uv sync --no-install-project
cd ..
```
This creates `backend/.venv` and installs pinned Python dependencies from `backend/uv.lock`.
## 4) Configure environment variables
```bash
cp .env.example .env
```
Then set at least:
- `POSTGRES_URL`
- `OPENAI_API_KEY`
Optional:
- `OPENAI_MODEL` (single model used for all generation stages, defaults to `gpt-5.4-mini`)
- `GITHUB_PAT`
- `NEXT_PUBLIC_POSTHOG_KEY`
- `NEXT_PUBLIC_USE_LEGACY_BACKEND=true` and `NEXT_PUBLIC_API_DEV_URL` (to route frontend calls to an external backend such as Railway/local FastAPI)
## 5) Start local services
Start local Postgres (if using local DB URL):
```bash
chmod +x start-database.sh
./start-database.sh
```
Push schema:
```bash
pnpm db:push
```
Start frontend:
```bash
pnpm dev
```
Start FastAPI backend (recommended for production parity):
```bash
docker-compose up --build -d
docker-compose logs -f api
```
or
```bash
pnpm dev:backend
```
If the FastAPI backend is running locally at `http://localhost:8000`, set:
- `NEXT_PUBLIC_USE_LEGACY_BACKEND=true`
- `NEXT_PUBLIC_API_DEV_URL=http://localhost:8000`
## 6) Verification commands
Run all baseline checks:
```bash
pnpm check
pnpm test
pnpm build
```
FastAPI backend checks:
```bash
cd backend
uv run pytest -q
uv run python -m compileall app
cd ..
```
If all pass, your local environment is ready.
================================================
FILE: docs/railway-backend.md
================================================
# Railway Backend Deploy Guide
This guide deploys the production FastAPI backend from this monorepo.
## 1) Prerequisites
- Railway account + project access
- Railway CLI installed
- Logged in locally:
```bash
railway login
```
## 2) Create/link the Railway service
You can use dashboard or CLI. CLI flow:
```bash
cd /path/to/gitdiagram
railway init -n gitdiagram
railway add --service gitdiagram-api
railway link --service gitdiagram-api
```
## 3) Set backend environment variables
Required:
- `OPENAI_API_KEY`
Recommended:
- `OPENAI_MODEL=gpt-5.4-mini`
- `ENVIRONMENT=production`
- `WEB_CONCURRENCY=2`
- `CORS_ORIGINS=https://gitdiagram.com,https://www.gitdiagram.com,https://<your-vercel-domain>`
Optional:
- `GITHUB_PAT` (higher GitHub API rate limits for repository fetches)
- `GITHUB_CLIENT_ID`
- `GITHUB_PRIVATE_KEY`
- `GITHUB_INSTALLATION_ID`
- `API_ANALYTICS_KEY`
Set variables via CLI:
```bash
railway variables --service gitdiagram-api --set "OPENAI_API_KEY=..."
railway variables --service gitdiagram-api --set "OPENAI_MODEL=gpt-5.4-mini"
railway variables --service gitdiagram-api --set "ENVIRONMENT=production"
railway variables --service gitdiagram-api --set "WEB_CONCURRENCY=2"
railway variables --service gitdiagram-api --set "CORS_ORIGINS=https://gitdiagram.com,https://www.gitdiagram.com,https://<your-vercel-domain>"
```
Do not set `PORT` manually unless needed. Railway injects it automatically.
## 4) Deploy backend from `backend/`
```bash
cd /path/to/gitdiagram
railway up --service gitdiagram-api --path-as-root backend
```
## 5) Create a public Railway domain
```bash
railway domain --service gitdiagram-api
```
Copy the generated URL, for example:
`https://gitdiagram-api-production-xxxx.up.railway.app`
## 6) Point Vercel frontend to Railway backend
In your Vercel project environment variables, set:
- `NEXT_PUBLIC_USE_LEGACY_BACKEND=true`
- `NEXT_PUBLIC_API_DEV_URL=https://<your-railway-domain>`
Then redeploy Vercel.
Note: the variable name includes "LEGACY" for backward compatibility, but this is now the primary external backend path.
## 7) Verify
1. Health endpoint:
- `GET https://<your-railway-domain>/healthz`
- expected JSON: `{"ok": true, "status": "ok"}`
2. Open your frontend and generate a diagram.
3. Check Railway logs:
```bash
railway logs --service gitdiagram-api
```
================================================
FILE: drizzle.config.ts
================================================
import { type Config } from "drizzle-kit";
import { env } from "~/env";
export default {
schema: "./src/server/db/schema.ts",
dialect: "postgresql",
dbCredentials: {
url: env.POSTGRES_URL,
},
tablesFilter: ["gitdiagram_*"],
} satisfies Config;
================================================
FILE: eslint.config.mjs
================================================
import nextCoreVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";
import drizzle from "eslint-plugin-drizzle";
import tseslint from "@typescript-eslint/eslint-plugin";
const config = [
...nextCoreVitals,
...nextTypescript,
{
ignores: [
".next/**",
"node_modules/**",
"backend/**",
"dist/**",
"coverage/**",
"next-env.d.ts",
],
},
{
files: ["**/*.{ts,tsx}"],
plugins: {
drizzle,
"@typescript-eslint": tseslint,
},
rules: {
"@typescript-eslint/array-type": "off",
"@typescript-eslint/consistent-type-definitions": "off",
"@typescript-eslint/consistent-type-imports": [
"warn",
{
prefer: "type-imports",
fixStyle: "inline-type-imports",
},
],
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
},
],
"@typescript-eslint/require-await": "off",
"react-hooks/set-state-in-effect": "off",
"drizzle/enforce-delete-with-where": [
"error",
{
drizzleObjectName: ["db", "ctx.db"],
},
],
"drizzle/enforce-update-with-where": [
"error",
{
drizzleObjectName: ["db", "ctx.db"],
},
],
},
},
];
export default config;
================================================
FILE: next.config.js
================================================
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
import "./src/env.js";
/** @type {import("next").NextConfig} */
const config = {
reactStrictMode: false,
async rewrites() {
return [
{
source: "/phx9a/static/:path*",
destination: "https://us-assets.i.posthog.com/static/:path*",
},
{
source: "/phx9a/:path*",
destination: "https://us.i.posthog.com/:path*",
},
];
},
// This is required to support PostHog trailing slash API requests
skipTrailingSlashRedirect: true,
};
export default config;
================================================
FILE: package.json
================================================
{
"name": "gitdiagram",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"check": "pnpm lint && tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"dev": "next dev --turbo",
"dev:backend": "cd backend && ENVIRONMENT=development uv run --no-dev uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"preview": "next build && next start",
"start": "next start",
"start:backend": "cd backend && ENVIRONMENT=production uv run --no-dev uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}",
"test": "vitest run",
"test:backend": "cd backend && uv run pytest -q",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache"
},
"dependencies": {
"@mermaid-js/layout-elk": "^0.2.1",
"@neondatabase/serverless": "^1.0.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8",
"@t3-oss/env-nextjs": "^0.13.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.3.1",
"dotenv": "^17.3.1",
"drizzle-orm": "^0.45.1",
"geist": "^1.7.0",
"ldrs": "^1.1.9",
"lucide-react": "^0.574.0",
"mermaid": "^11.12.3",
"next": "^16.1.6",
"next-themes": "^0.4.6",
"openai": "^6.22.0",
"postgres": "^3.4.8",
"posthog-js": "^1.351.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-icons": "^5.5.0",
"sonner": "^2.0.7",
"svg-pan-zoom": "^3.6.2",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "4.2.0",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@types/eslint": "^9.6.1",
"@types/node": "^25.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.56.0",
"@typescript-eslint/parser": "^8.56.0",
"drizzle-kit": "^0.31.9",
"eslint": "^9.39.2",
"eslint-config-next": "^16.1.6",
"eslint-plugin-drizzle": "^0.2.3",
"jsdom": "26.1.0",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwind-scrollbar": "^4.0.2",
"tailwindcss": "^4.2.0",
"typescript": "^5.9.3",
"vitest": "4.0.18"
},
"ct3aMetadata": {
"initVersion": "7.38.1"
},
"engines": {
"node": ">=22 <24",
"pnpm": ">=9 <11"
},
"packageManager": "pnpm@10.30.0"
}
================================================
FILE: postcss.config.js
================================================
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
================================================
FILE: prettier.config.js
================================================
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
const config = {
plugins: ["prettier-plugin-tailwindcss"],
};
export default config;
================================================
FILE: src/app/[username]/[repo]/page.tsx
================================================
import type { Metadata } from "next";
import RepoPageClient from "./repo-page-client";
type RepoPageProps = {
params: Promise<{ username: string; repo: string }>;
};
export async function generateMetadata({
params,
}: RepoPageProps): Promise<Metadata> {
const { username, repo } = await params;
return {
title: `${username}/${repo} Diagram | GitDiagram`,
description: `Interactive architecture diagram for ${username}/${repo}.`,
};
}
export default async function Repo({ params }: RepoPageProps) {
const { username, repo } = await params;
return <RepoPageClient username={username} repo={repo} />;
}
================================================
FILE: src/app/[username]/[repo]/repo-page-client.tsx
================================================
"use client";
import { useState } from "react";
import MainCard from "~/components/main-card";
import Loading from "~/components/loading";
import MermaidChart from "~/components/mermaid-diagram";
import { useDiagram } from "~/hooks/useDiagram";
import { ApiKeyDialog } from "~/components/api-key-dialog";
import { ApiKeyButton } from "~/components/api-key-button";
import { useStarReminder } from "~/hooks/useStarReminder";
type RepoPageClientProps = {
username: string;
repo: string;
};
export default function RepoPageClient({ username, repo }: RepoPageClientProps) {
const [zoomingEnabled, setZoomingEnabled] = useState(false);
useStarReminder();
const normalizedUsername = username.toLowerCase();
const normalizedRepo = repo.toLowerCase();
const {
diagram,
error,
loading,
lastGenerated,
cost,
showApiKeyDialog,
handleCopy,
handleApiKeySubmit,
handleCloseApiKeyDialog,
handleOpenApiKeyDialog,
handleExportImage,
handleRegenerate,
state,
} = useDiagram(normalizedUsername, normalizedRepo);
return (
<div className="flex flex-col items-center p-4">
<div className="flex w-full justify-center pt-8">
<MainCard
isHome={false}
username={normalizedUsername}
repo={normalizedRepo}
onCopy={handleCopy}
lastGenerated={lastGenerated}
onExportImage={handleExportImage}
onRegenerate={handleRegenerate}
zoomingEnabled={zoomingEnabled}
onZoomToggle={() => setZoomingEnabled((prev) => !prev)}
loading={loading}
/>
</div>
<div className="mt-8 flex w-full flex-col items-center gap-8">
{loading ? (
<Loading
cost={cost}
status={state.status}
message={state.message}
parserError={state.parserError}
fixAttempt={state.fixAttempt}
fixMaxAttempts={state.fixMaxAttempts}
fixDiagramDraft={state.fixDiagramDraft}
explanation={state.explanation}
mapping={state.mapping}
diagram={state.diagram}
/>
) : error || state.error ? (
<div className="mt-12 text-center">
<p className="max-w-4xl text-lg font-medium text-red-700 dark:text-red-300">
{error || state.error}
</p>
{state.parserError && (
<pre className="mx-auto mt-4 max-w-4xl overflow-x-auto whitespace-pre-wrap rounded-md border border-neutral-300 bg-neutral-100 p-4 text-left text-xs text-neutral-800 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200">
{state.parserError}
</pre>
)}
{(error?.includes("API key") ||
state.error?.includes("API key")) && (
<div className="mt-8 flex flex-col items-center gap-2">
<ApiKeyButton onClick={handleOpenApiKeyDialog} />
</div>
)}
</div>
) : (
<div className="flex w-full justify-center px-4">
<MermaidChart chart={diagram} zoomingEnabled={zoomingEnabled} />
</div>
)}
</div>
<ApiKeyDialog
isOpen={showApiKeyDialog}
onClose={handleCloseApiKeyDialog}
onSubmit={handleApiKeySubmit}
/>
</div>
);
}
================================================
FILE: src/app/_actions/cache.ts
================================================
"use server";
import { db } from "~/server/db";
import { eq, and } from "drizzle-orm";
import { diagramCache } from "~/server/db/schema";
import { sql } from "drizzle-orm";
export async function getCachedDiagram(username: string, repo: string) {
try {
const cached = await db
.select()
.from(diagramCache)
.where(
and(eq(diagramCache.username, username), eq(diagramCache.repo, repo)),
)
.limit(1);
return cached[0]?.diagram ?? null;
} catch (error) {
console.error("Error fetching cached diagram:", error);
return null;
}
}
export async function getCachedExplanation(username: string, repo: string) {
try {
const cached = await db
.select()
.from(diagramCache)
.where(
and(eq(diagramCache.username, username), eq(diagramCache.repo, repo)),
)
.limit(1);
return cached[0]?.explanation ?? null;
} catch (error) {
console.error("Error fetching cached explanation:", error);
return null;
}
}
export async function cacheDiagramAndExplanation(
username: string,
repo: string,
diagram: string,
explanation: string,
usedOwnKey = false,
) {
try {
await db
.insert(diagramCache)
.values({
username,
repo,
diagram,
explanation,
usedOwnKey,
})
.onConflictDoUpdate({
target: [diagramCache.username, diagramCache.repo],
set: {
diagram,
explanation,
usedOwnKey,
updatedAt: new Date(),
},
});
} catch (error) {
console.error("Error caching diagram:", error);
}
}
export async function getDiagramStats() {
try {
const stats = await db
.select({
totalDiagrams: sql`COUNT(*)`,
ownKeyUsers: sql`COUNT(CASE WHEN ${diagramCache.usedOwnKey} = true THEN 1 END)`,
freeUsers: sql`COUNT(CASE WHEN ${diagramCache.usedOwnKey} = false THEN 1 END)`,
})
.from(diagramCache);
return stats[0];
} catch (error) {
console.error("Error getting diagram stats:", error);
return null;
}
}
================================================
FILE: src/app/_actions/repo.ts
================================================
"use server";
import { db } from "~/server/db";
import { eq, and } from "drizzle-orm";
import { diagramCache } from "~/server/db/schema";
export async function getLastGeneratedDate(username: string, repo: string) {
const result = await db
.select()
.from(diagramCache)
.where(
and(eq(diagramCache.username, username), eq(diagramCache.repo, repo)),
);
return result[0]?.updatedAt;
}
================================================
FILE: src/app/api/generate/cost/route.ts
================================================
import { NextResponse } from "next/server";
import { toTaggedMessage } from "~/server/generate/format";
import { getGithubData } from "~/server/generate/github";
import { getModel } from "~/server/generate/model-config";
import { countInputTokens, estimateTokens } from "~/server/generate/openai";
import { SYSTEM_FIRST_PROMPT } from "~/server/generate/prompts";
import { estimateTextTokenCostUsd } from "~/server/generate/pricing";
import { generateRequestSchema } from "~/server/generate/types";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 300;
const MULTI_STAGE_INPUT_MULTIPLIER = 2;
const INPUT_OVERHEAD_TOKENS = 3000;
const ESTIMATED_OUTPUT_TOKENS = 8000;
async function estimateRepoInputTokens(
model: string,
fileTree: string,
readme: string,
apiKey?: string,
) {
try {
return await countInputTokens({
model,
systemPrompt: SYSTEM_FIRST_PROMPT,
userPrompt: toTaggedMessage({
file_tree: fileTree,
readme,
}),
apiKey,
reasoningEffort: "medium",
});
} catch {
return estimateTokens(`${fileTree}\n${readme}`);
}
}
export async function POST(request: Request) {
try {
const parsed = generateRequestSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json({
ok: false,
error: "Invalid request payload.",
error_code: "VALIDATION_ERROR",
});
}
const {
username,
repo,
api_key: apiKey,
github_pat: githubPat,
} = parsed.data;
const githubData = await getGithubData(username, repo, githubPat);
const model = getModel();
const baseInputTokens = await estimateRepoInputTokens(
model,
githubData.fileTree,
githubData.readme,
apiKey,
);
const estimatedInputTokens =
baseInputTokens * MULTI_STAGE_INPUT_MULTIPLIER + INPUT_OVERHEAD_TOKENS;
const estimatedOutputTokens = ESTIMATED_OUTPUT_TOKENS;
const { costUsd, pricingModel, pricing } = estimateTextTokenCostUsd(
model,
estimatedInputTokens,
estimatedOutputTokens,
);
return NextResponse.json({
ok: true,
cost: `$${costUsd.toFixed(2)} USD`,
model,
pricing_model: pricingModel,
estimated_input_tokens: estimatedInputTokens,
estimated_output_tokens: estimatedOutputTokens,
pricing: {
input_per_million_usd: pricing.inputPerMillionUsd,
output_per_million_usd: pricing.outputPerMillionUsd,
},
});
} catch (error) {
return NextResponse.json({
ok: false,
error:
error instanceof Error
? error.message
: "Failed to estimate generation cost.",
error_code: "COST_ESTIMATION_FAILED",
});
}
}
================================================
FILE: src/app/api/generate/stream/route.ts
================================================
import { getModel } from "~/server/generate/model-config";
import {
extractComponentMapping,
processClickEvents,
stripMermaidCodeFences,
toTaggedMessage,
} from "~/server/generate/format";
import { getGithubData } from "~/server/generate/github";
import {
formatValidationFeedback,
validateMermaidSyntax,
} from "~/server/generate/mermaid";
import {
countInputTokens,
estimateTokens,
streamCompletion,
} from "~/server/generate/openai";
import {
SYSTEM_FIRST_PROMPT,
SYSTEM_FIX_MERMAID_PROMPT,
SYSTEM_SECOND_PROMPT,
SYSTEM_THIRD_PROMPT,
} from "~/server/generate/prompts";
import { generateRequestSchema, sseMessage } from "~/server/generate/types";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 300;
const MAX_MERMAID_FIX_ATTEMPTS = 3;
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function estimateRepoTokenCount(
model: string,
fileTree: string,
readme: string,
apiKey?: string,
) {
try {
return await countInputTokens({
model,
systemPrompt: SYSTEM_FIRST_PROMPT,
userPrompt: toTaggedMessage({
file_tree: fileTree,
readme,
}),
apiKey,
reasoningEffort: "medium",
});
} catch {
return estimateTokens(`${fileTree}\n${readme}`);
}
}
export async function POST(request: Request) {
const parsed = generateRequestSchema.safeParse(await request.json());
if (!parsed.success) {
return new Response(
JSON.stringify({
ok: false,
error: "Invalid request payload.",
error_code: "VALIDATION_ERROR",
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const { username, repo, api_key: apiKey, github_pat: githubPat } = parsed.data;
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const send = (payload: Record<string, unknown>) => {
controller.enqueue(encoder.encode(sseMessage(payload)));
};
const run = async () => {
try {
const githubData = await getGithubData(username, repo, githubPat);
const model = getModel();
const tokenCount = await estimateRepoTokenCount(
model,
githubData.fileTree,
githubData.readme,
apiKey,
);
send({
status: "started",
message: "Starting generation process...",
});
if (tokenCount > 50000 && tokenCount < 195000 && !apiKey) {
send({
status: "error",
error:
"File tree and README combined exceeds token limit (50,000). This repository is too large for free generation. Provide your own OpenAI API key to continue.",
error_code: "API_KEY_REQUIRED",
});
controller.close();
return;
}
if (tokenCount > 195000) {
send({
status: "error",
error:
"Repository is too large (>195k tokens) for analysis. Try a smaller repo.",
error_code: "TOKEN_LIMIT_EXCEEDED",
});
controller.close();
return;
}
send({
status: "explanation_sent",
message: `Sending explanation request to ${model}...`,
});
await sleep(80);
send({
status: "explanation",
message: "Analyzing repository structure...",
});
let explanation = "";
for await (const chunk of streamCompletion({
model,
systemPrompt: SYSTEM_FIRST_PROMPT,
userPrompt: toTaggedMessage({
file_tree: githubData.fileTree,
readme: githubData.readme,
}),
apiKey,
reasoningEffort: "medium",
})) {
explanation += chunk;
send({ status: "explanation_chunk", chunk });
}
send({
status: "mapping_sent",
message: `Sending component mapping request to ${model}...`,
});
await sleep(80);
send({
status: "mapping",
message: "Creating component mapping...",
});
let fullMappingResponse = "";
for await (const chunk of streamCompletion({
model,
systemPrompt: SYSTEM_SECOND_PROMPT,
userPrompt: toTaggedMessage({
explanation,
file_tree: githubData.fileTree,
}),
apiKey,
reasoningEffort: "low",
})) {
fullMappingResponse += chunk;
send({ status: "mapping_chunk", chunk });
}
const componentMapping = extractComponentMapping(fullMappingResponse);
send({
status: "diagram_sent",
message: `Sending diagram generation request to ${model}...`,
});
await sleep(80);
send({
status: "diagram",
message: "Generating diagram...",
});
let mermaidCode = "";
for await (const chunk of streamCompletion({
model,
systemPrompt: SYSTEM_THIRD_PROMPT,
userPrompt: toTaggedMessage({
explanation,
component_mapping: componentMapping,
}),
apiKey,
reasoningEffort: "low",
})) {
mermaidCode += chunk;
send({ status: "diagram_chunk", chunk });
}
let candidateDiagram = stripMermaidCodeFences(mermaidCode);
let validationResult = await validateMermaidSyntax(candidateDiagram);
const hadFixLoop = !validationResult.valid;
if (!validationResult.valid) {
const parserFeedback = formatValidationFeedback(validationResult);
send({
status: "diagram_fixing",
message:
"Diagram generated. Mermaid syntax validation failed, starting auto-fix loop...",
parser_error: parserFeedback,
});
}
for (
let attempt = 1;
!validationResult.valid && attempt <= MAX_MERMAID_FIX_ATTEMPTS;
attempt++
) {
const parserFeedback = formatValidationFeedback(validationResult);
send({
status: "diagram_fix_attempt",
message: `Fixing Mermaid syntax (attempt ${attempt}/${MAX_MERMAID_FIX_ATTEMPTS})...`,
fix_attempt: attempt,
fix_max_attempts: MAX_MERMAID_FIX_ATTEMPTS,
parser_error: parserFeedback,
});
let repairedDiagram = "";
for await (const chunk of streamCompletion({
model,
systemPrompt: SYSTEM_FIX_MERMAID_PROMPT,
userPrompt: toTaggedMessage({
mermaid_code: candidateDiagram,
parser_error: parserFeedback,
explanation,
component_mapping: componentMapping,
}),
apiKey,
reasoningEffort: "low",
})) {
repairedDiagram += chunk;
send({
status: "diagram_fix_chunk",
chunk,
fix_attempt: attempt,
fix_max_attempts: MAX_MERMAID_FIX_ATTEMPTS,
});
}
candidateDiagram = stripMermaidCodeFences(repairedDiagram);
send({
status: "diagram_fix_validating",
message: `Validating Mermaid syntax after attempt ${attempt}/${MAX_MERMAID_FIX_ATTEMPTS}...`,
fix_attempt: attempt,
fix_max_attempts: MAX_MERMAID_FIX_ATTEMPTS,
});
validationResult = await validateMermaidSyntax(candidateDiagram);
}
if (!validationResult.valid) {
send({
status: "error",
error:
"Generated Mermaid remained syntactically invalid after auto-fix attempts. Please retry generation.",
error_code: "MERMAID_SYNTAX_UNRESOLVED",
parser_error: formatValidationFeedback(validationResult),
});
return;
}
const processedDiagram = processClickEvents(
candidateDiagram,
username,
repo,
githubData.defaultBranch,
);
if (hadFixLoop) {
send({
status: "diagram_fixing",
message: "Mermaid syntax validated. Finalizing diagram output...",
});
}
send({
status: "complete",
diagram: processedDiagram,
explanation,
mapping: componentMapping,
});
} catch (error) {
send({
status: "error",
error:
error instanceof Error
? error.message
: "Streaming generation failed.",
error_code: "STREAM_FAILED",
});
} finally {
controller.close();
}
};
void run();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}
================================================
FILE: src/app/api/healthz/route.ts
================================================
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
return NextResponse.json({ ok: true, status: "ok" });
}
================================================
FILE: src/app/layout.tsx
================================================
import "~/styles/globals.css";
import { GeistSans } from "geist/font/sans";
import { type Metadata } from "next";
import { Header } from "~/components/header";
import { Footer } from "~/components/footer";
import { CSPostHogProvider } from "./providers";
import { Toaster } from "~/components/ui/sonner";
export const metadata: Metadata = {
title: "GitDiagram",
description:
"Turn any GitHub repository into an interactive diagram for visualization in seconds.",
metadataBase: new URL("https://gitdiagram.com"),
keywords: [
"github",
"git diagram",
"git diagram generator",
"git diagram tool",
"git diagram maker",
"git diagram creator",
"git diagram",
"diagram",
"repository",
"visualization",
"code structure",
"system design",
"software architecture",
"software design",
"software engineering",
"software development",
"software architecture",
"software design",
"software engineering",
"software development",
"open source",
"open source software",
"ahmedkhaleel2004",
"ahmed khaleel",
"gitdiagram",
"gitdiagram.com",
],
authors: [
{ name: "Ahmed Khaleel", url: "https://github.com/ahmedkhaleel2004" },
],
creator: "Ahmed Khaleel",
openGraph: {
type: "website",
locale: "en_US",
url: "https://gitdiagram.com",
title: "GitDiagram - Repository to Diagram in Seconds",
description:
"Turn any GitHub repository into an interactive diagram for visualization.",
siteName: "GitDiagram",
images: [
{
url: "/og-image.png", // You'll need to create this image
width: 1200,
height: 630,
alt: "GitDiagram - Repository Visualization Tool",
},
],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-snippet": -1,
},
},
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html
lang="en"
suppressHydrationWarning
className={`${GeistSans.variable}`}
>
<body className="flex min-h-screen flex-col">
<CSPostHogProvider>
<Header />
<main className="flex-grow">{children}</main>
<Footer />
<Toaster />
</CSPostHogProvider>
</body>
</html>
);
}
================================================
FILE: src/app/page.tsx
================================================
import MainCard from "~/components/main-card";
import Hero from "~/components/hero";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "GitDiagram - Visualize Any GitHub Repository",
description:
"Turn any GitHub repository into an interactive architecture diagram for quick codebase understanding.",
};
export default function HomePage() {
return (
<main className="flex-grow px-8 pb-8 md:p-8">
<div className="mx-auto mb-4 max-w-4xl lg:my-8">
<Hero />
<div className="mt-12"></div>
<p className="mx-auto mt-8 max-w-2xl text-center text-lg">
Turn any GitHub repository into an interactive diagram for
visualization.
</p>
<p className="mx-auto mt-0 max-w-2xl text-center text-lg">
This is useful for quickly visualizing projects.
</p>
<p className="mx-auto mt-2 max-w-2xl text-center text-lg">
You can also replace 'hub' with 'diagram' in any
Github URL
</p>
</div>
<div className="mb-16 flex justify-center lg:mb-0">
<MainCard />
</div>
</main>
);
}
================================================
FILE: src/app/providers.tsx
================================================
// app/providers.js
"use client";
import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import { ThemeProvider } from "next-themes";
if (typeof window !== "undefined") {
// Only initialize PostHog if the environment variables are available
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
if (posthogKey) {
posthog.init(posthogKey, {
// Use a non-default first-party path to reduce adblock filter hits.
api_host: "/phx9a",
ui_host: "https://us.posthog.com",
person_profiles: "always",
});
} else {
console.log(
"PostHog environment variables are not set. Analytics will be disabled. Skipping PostHog initialization.",
);
}
}
export function CSPostHogProvider({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem={false}
storageKey="gitdiagram-theme"
>
<PostHogProvider client={posthog}>{children}</PostHogProvider>
</ThemeProvider>
);
}
================================================
FILE: src/components/action-button.tsx
================================================
import { Button } from "~/components/ui/button";
import type { LucideIcon } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
interface ActionButtonProps {
onClick: () => void;
icon: LucideIcon;
tooltipText: string;
disabled?: boolean;
text?: string;
}
export function ActionButton({
onClick,
icon: Icon,
tooltipText,
disabled,
text,
}: ActionButtonProps) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={(e) => {
e.preventDefault();
onClick();
}}
disabled={disabled}
className="neo-button p-4 px-4 text-base sm:p-6 sm:px-6 sm:text-lg"
>
<Icon className="h-6 w-6" />
{text && <span className="text-sm">{text}</span>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
================================================
FILE: src/components/api-key-button.tsx
================================================
import { Key } from "lucide-react";
import { Button } from "./ui/button";
interface ApiKeyButtonProps {
onClick: () => void;
}
export function ApiKeyButton({ onClick }: ApiKeyButtonProps) {
return (
<Button
onClick={onClick}
className="neo-button px-4 py-2"
>
<Key className="mr-2 h-5 w-5" />
Use Your API Key
</Button>
);
}
================================================
FILE: src/components/api-key-dialog.tsx
================================================
"use client";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
import { Input } from "./ui/input";
import { Button } from "./ui/button";
import { useState, useEffect } from "react";
import Link from "next/link";
interface ApiKeyDialogProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (apiKey: string) => void;
}
export function ApiKeyDialog({ isOpen, onClose, onSubmit }: ApiKeyDialogProps) {
const [apiKey, setApiKey] = useState<string>("");
useEffect(() => {
const storedKey = localStorage.getItem("openai_key");
if (storedKey) {
setApiKey(storedKey);
}
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(apiKey);
setApiKey("");
};
const handleClear = () => {
localStorage.removeItem("openai_key");
setApiKey("");
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="neo-panel p-6 sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-xl font-bold text-black dark:text-neutral-100">
Enter OpenAI API Key
</DialogTitle>
</DialogHeader>
<form
onSubmit={handleSubmit}
className="space-y-4 text-black dark:text-neutral-200"
>
<div className="text-sm">
GitDiagram offers infinite free diagram generations! You can also
provide an OpenAI API key to generate diagrams at your own cost. The
key will be stored locally in your browser.
{/* GitDiagram offers one free diagram generation. For additional
diagrams, you'll need to provide an OpenAI API key. The key
will be stored locally in your browser. */}
<br />
<br />
<span className="font-medium">Get your OpenAI API key </span>
<Link
href="https://platform.openai.com/api-keys"
className="neo-link font-medium"
>
here
</Link>
.
</div>
<details className="group text-sm [&>summary:focus-visible]:outline-none">
<summary className="neo-link cursor-pointer font-medium">
Data storage disclaimer
</summary>
<div className="animate-accordion-down mt-2 space-y-2 overflow-hidden pl-2">
<p>
Your API key will be stored locally in your browser and used
only for generating diagrams. You can also self-host this app by
following the instructions in the{" "}
<Link
href="https://github.com/ahmedkhaleel2004/gitdiagram"
className="neo-link"
>
README
</Link>
.
</p>
</div>
</details>
<Input
type="password"
placeholder="sk-..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="neo-input flex-1 rounded-md px-3 py-2 text-base font-bold placeholder:text-base placeholder:font-normal placeholder:text-gray-700 dark:placeholder:text-neutral-400"
required
/>
<div className="flex items-center justify-between">
<button
type="button"
onClick={handleClear}
className="neo-link text-sm"
>
Clear
</button>
<div className="flex gap-3">
<Button
type="button"
onClick={onClose}
className="neo-button-muted px-4 py-2"
>
Cancel
</Button>
<Button
type="submit"
disabled={!apiKey.startsWith("sk-")}
className="neo-button px-4 py-2 disabled:opacity-50"
>
Save Key
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
);
}
================================================
FILE: src/components/copy-button.tsx
================================================
import React, { useState } from "react";
import { Button } from "~/components/ui/button";
import { FileText, Check } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
interface CopyButtonProps {
onClick: () => void;
}
export function CopyButton({ onClick }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const handleClick = () => {
onClick();
setCopied(true);
setTimeout(() => setCopied(false), 2000); // Reset after 2 seconds
};
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={handleClick}
className="neo-button p-4 px-4 text-base sm:p-6 sm:px-6 sm:text-lg"
>
{copied ? (
<>
<Check className="h-6 w-6" />
<span className="text-sm">Copied!</span>
</>
) : (
<>
<FileText className="h-6 w-6" />
<span className="text-sm">Copy Mermaid.js Code</span>
</>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{copied
? "Copied!"
: "Copy the internal Mermaid.js code needed to generate the diagram"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
================================================
FILE: src/components/export-dropdown.tsx
================================================
import { CopyButton } from "./copy-button";
import { Image } from "lucide-react";
import { ActionButton } from "./action-button";
interface ExportDropdownProps {
onCopy: () => void;
lastGenerated: Date;
onExportImage: () => void;
isOpen: boolean;
}
export function ExportDropdown({
onCopy,
lastGenerated,
onExportImage,
}: ExportDropdownProps) {
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:gap-4">
<ActionButton
onClick={onExportImage}
icon={Image}
tooltipText="Download diagram as high-quality PNG"
text="Download PNG"
/>
<CopyButton onClick={onCopy} />
</div>
<div className="flex items-center">
<span className="text-sm text-gray-700 dark:text-neutral-300">
Last generated: {lastGenerated.toLocaleString()}
</span>
</div>
</div>
);
}
================================================
FILE: src/components/footer.tsx
================================================
import React from "react";
import Link from "next/link";
export function Footer() {
return (
<footer className="mt-auto border-t-[3px] border-black py-4 lg:px-8 dark:border-black">
<div className="container mx-auto flex h-8 max-w-4xl items-center justify-center">
<span className="text-sm font-medium text-black dark:text-neutral-100">
Made by{" "}
<Link
href="https://ahmedkhaleel.com"
className="neo-link hover:underline"
>
Ahmed Khaleel
</Link>
</span>
</div>
</footer>
);
}
================================================
FILE: src/components/header-client.tsx
================================================
"use client";
import { useState } from "react";
import Link from "next/link";
import { FaGithub } from "react-icons/fa";
import { ApiKeyDialog } from "./api-key-dialog";
import { PrivateReposDialog } from "./private-repos-dialog";
import { ThemeToggle } from "./theme-toggle";
interface HeaderClientProps {
starCount: number | null;
}
const compactNumberFormatter = new Intl.NumberFormat("en", {
notation: "compact",
maximumFractionDigits: 1,
});
function formatStarCount(count: number) {
return compactNumberFormatter.format(count).toLowerCase();
}
export function HeaderClient({ starCount }: HeaderClientProps) {
const [isPrivateReposDialogOpen, setIsPrivateReposDialogOpen] =
useState(false);
const [isApiKeyDialogOpen, setIsApiKeyDialogOpen] = useState(false);
const handlePrivateReposSubmit = (pat: string) => {
localStorage.setItem("github_pat", pat);
setIsPrivateReposDialogOpen(false);
};
const handleApiKeySubmit = (apiKey: string) => {
localStorage.setItem("openai_key", apiKey);
setIsApiKeyDialogOpen(false);
};
return (
<header className="border-b-[3px] border-black dark:border-black">
<div className="mx-auto flex h-16 max-w-4xl items-center justify-between px-4 sm:px-8">
<Link href="/" className="flex items-center">
<span className="text-lg font-semibold sm:text-xl">
<span className="text-black transition-colors duration-200 hover:text-gray-600 dark:text-white dark:hover:text-[hsl(var(--neo-button-hover))]">
Git
</span>
<span className="text-purple-600 transition-colors duration-200 hover:text-purple-500 dark:text-[hsl(var(--neo-button))] dark:hover:text-[hsl(var(--neo-button-hover))]">
Diagram
</span>
</span>
</Link>
<nav className="flex items-center gap-3 sm:gap-6">
<button
type="button"
onClick={() => setIsApiKeyDialogOpen(true)}
className="text-sm font-medium text-black transition-transform hover:translate-y-[-2px] hover:text-purple-600 dark:text-neutral-200 dark:hover:text-[hsl(var(--neo-link-hover))]"
>
<span className="flex items-center sm:hidden">
<span>API Key</span>
</span>
<span className="hidden items-center gap-1 sm:flex">
<span>API Key</span>
</span>
</button>
<button
type="button"
onClick={() => setIsPrivateReposDialogOpen(true)}
className="text-sm font-medium text-black transition-transform hover:translate-y-[-2px] hover:text-purple-600 dark:text-neutral-200 dark:hover:text-[hsl(var(--neo-link-hover))]"
>
<span className="sm:hidden">Private Repos</span>
<span className="hidden sm:inline">Private Repos</span>
</button>
<ThemeToggle />
<Link
href="https://github.com/ahmedkhaleel2004/gitdiagram"
className="flex items-center gap-1 text-sm font-medium text-black transition-transform hover:translate-y-[-2px] hover:text-purple-600 dark:text-neutral-200 dark:hover:text-[hsl(var(--neo-link-hover))] sm:gap-2"
>
<FaGithub className="h-5 w-5" />
<span className="hidden sm:inline">GitHub</span>
</Link>
{starCount !== null ? (
<span className="flex items-center gap-1 text-sm font-medium text-black dark:text-neutral-200">
<span className="text-amber-400 dark:text-[hsl(var(--neo-link))]">
★
</span>
{formatStarCount(starCount)}
</span>
) : null}
</nav>
<PrivateReposDialog
isOpen={isPrivateReposDialogOpen}
onClose={() => setIsPrivateReposDialogOpen(false)}
onSubmit={handlePrivateReposSubmit}
/>
<ApiKeyDialog
isOpen={isApiKeyDialogOpen}
onClose={() => setIsApiKeyDialogOpen(false)}
onSubmit={handleApiKeySubmit}
/>
</div>
</header>
);
}
================================================
FILE: src/components/header.tsx
================================================
import { getStarCount } from "~/server/github-stars";
import { HeaderClient } from "./header-client";
export async function Header() {
const starCount = await getStarCount();
return <HeaderClient starCount={starCount} />;
}
================================================
FILE: src/components/hero.tsx
================================================
import React from "react";
const Hero = () => {
return (
<div className="relative mx-auto flex w-full flex-col items-start justify-center sm:flex-row sm:items-center">
<svg
className="left-0 h-auto w-16 flex-shrink-0 -translate-x-2 translate-y-4 p-2 sm:absolute sm:w-20 sm:-translate-y-16 md:relative md:w-24 md:-translate-y-0 md:translate-x-10 lg:absolute lg:ml-32 lg:-translate-x-full lg:-translate-y-10"
viewBox="0 0 91 98"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m35.878 14.162 1.333-5.369 1.933 5.183c4.47 11.982 14.036 21.085 25.828 24.467l5.42 1.555-5.209 2.16c-11.332 4.697-19.806 14.826-22.888 27.237l-1.333 5.369-1.933-5.183C34.56 57.599 24.993 48.496 13.201 45.114l-5.42-1.555 5.21-2.16c11.331-4.697 19.805-14.826 22.887-27.237Z"
className="fill-violet-500 stroke-black dark:fill-[hsl(var(--neo-button))] dark:stroke-black"
strokeWidth="3.445"
/>
<path
d="M79.653 5.729c-2.436 5.323-9.515 15.25-18.341 12.374m9.197 16.336c2.6-5.851 10.008-16.834 18.842-13.956m-9.738-15.07c-.374 3.787 1.076 12.078 9.869 14.943M70.61 34.6c.503-4.21-.69-13.346-9.49-16.214M14.922 65.967c1.338 5.677 6.372 16.756 15.808 15.659M18.21 95.832c-1.392-6.226-6.54-18.404-15.984-17.305m12.85-12.892c-.41 3.771-3.576 11.588-12.968 12.681M18.025 96c.367-4.21 3.453-12.905 12.854-14"
className="stroke-black dark:stroke-[hsl(var(--foreground))]"
strokeWidth="2.548"
strokeLinecap="round"
/>
</svg>
<h1 className="relative inline-block w-full text-center text-5xl font-bold tracking-tighter sm:text-5xl md:text-6xl lg:pt-5 lg:text-7xl">
Repository to <br />
diagram
</h1>
<svg
className="bottom-0 right-0 hidden h-auto w-16 flex-shrink-0 -translate-x-10 translate-y-10 md:block md:translate-y-20 lg:absolute lg:w-20 lg:-translate-x-12 lg:translate-y-4"
viewBox="0 0 92 80"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m35.213 16.953.595-5.261 2.644 4.587a35.056 35.056 0 0 0 26.432 17.33l5.261.594-4.587 2.644A35.056 35.056 0 0 0 48.23 63.28l-.595 5.26-2.644-4.587a35.056 35.056 0 0 0-26.432-17.328l-5.261-.595 4.587-2.644a35.056 35.056 0 0 0 17.329-26.433Z"
className="fill-sky-400 stroke-black dark:fill-[hsl(var(--neo-button-hover))] dark:stroke-black"
strokeWidth="2.868"
/>
<path
d="M75.062 40.108c1.07 5.255 1.072 16.52-7.472 19.54m7.422-19.682c1.836 2.965 7.643 8.14 16.187 5.121-8.544 3.02-8.207 15.23-6.971 20.957-1.97-3.343-8.044-9.274-16.588-6.254M12.054 28.012c1.34-5.22 6.126-15.4 14.554-14.369M12.035 28.162c-.274-3.487-2.93-10.719-11.358-11.75C9.104 17.443 14.013 6.262 15.414.542c.226 3.888 2.784 11.92 11.212 12.95"
className="stroke-black dark:stroke-[hsl(var(--foreground))]"
strokeWidth="2.319"
strokeLinecap="round"
/>
</svg>
</div>
);
};
export default Hero;
================================================
FILE: src/components/loading-animation.tsx
================================================
"use client";
import { trio } from "ldrs";
import { useTheme } from "next-themes";
trio.register();
const LoadingAnimation = () => {
const { resolvedTheme } = useTheme();
const color = resolvedTheme === "dark" ? "#f5f5f5" : "#171717";
return <l-trio size="40" speed="2.0" color={color} />;
};
export default LoadingAnimation;
================================================
FILE: src/components/loading.tsx
================================================
"use client";
import { useEffect, useState, useRef } from "react";
import type { DiagramStreamStatus } from "~/features/diagram/types";
const messages = [
"Checking if its cached...",
"Generating diagram...",
"Analyzing repository...",
"Prompting GPT-5.2...",
"Inspecting file paths...",
"Finding component relationships...",
"Linking components to code...",
"Extracting relevant directories...",
"Reasoning about the diagram...",
"Prompt engineers needed -> Check out the GitHub",
"Shoutout to GitIngest for inspiration",
"I need to find a way to make this faster...",
"Finding the meaning of life...",
"I'm tired...",
"Please just give me the diagram...",
"...NOW!",
"guess not...",
];
interface LoadingProps {
cost?: string;
status: DiagramStreamStatus;
message?: string;
parserError?: string;
fixAttempt?: number;
fixMaxAttempts?: number;
fixDiagramDraft?: string;
explanation?: string;
mapping?: string;
diagram?: string;
}
const getStepNumber = (status: string): number => {
if (status.startsWith("diagram")) return 3;
if (status.startsWith("mapping")) return 2;
if (status.startsWith("explanation")) return 1;
return 0;
};
const SequentialDots = () => {
return (
<span className="inline-flex w-8 justify-start">
<span className="flex gap-0.5">
<span className="h-1 w-1 animate-[dot1_1.5s_steps(1)_infinite] rounded-full bg-[hsl(var(--neo-dot-active))]" />
<span className="h-1 w-1 animate-[dot2_1.5s_steps(1)_infinite] rounded-full bg-[hsl(var(--neo-dot-active))]" />
<span className="h-1 w-1 animate-[dot3_1.5s_steps(1)_infinite] rounded-full bg-[hsl(var(--neo-dot-active))]" />
</span>
</span>
);
};
const StepDots = ({ currentStep }: { currentStep: number }) => {
return (
<div className="flex gap-1">
{[1, 2, 3].map((step) => (
<div
key={step}
className={`h-1.5 w-1.5 rounded-full transition-colors duration-300 ${
step <= currentStep
? "bg-[hsl(var(--neo-dot-active))]"
: "bg-[hsl(var(--neo-dot-inactive))]"
}`}
/>
))}
</div>
);
};
export default function Loading({
status = "idle",
message,
parserError,
fixAttempt,
fixMaxAttempts,
fixDiagramDraft,
explanation,
mapping,
diagram,
cost,
}: LoadingProps) {
const [currentMessageIndex, setCurrentMessageIndex] = useState(0);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const interval = setInterval(() => {
setCurrentMessageIndex((prevIndex) => (prevIndex + 1) % messages.length);
}, 3000);
return () => clearInterval(interval);
}, []);
// Auto-scroll effect
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [
status,
message,
parserError,
fixAttempt,
fixMaxAttempts,
fixDiagramDraft,
explanation,
mapping,
diagram,
]);
const shouldShowReasoning = (currentStatus: string) => {
if (
currentStatus === "diagram_fixing" ||
currentStatus === "diagram_fix_attempt" ||
currentStatus === "diagram_fix_chunk" ||
currentStatus === "diagram_fix_validating"
) {
return null;
}
if (
currentStatus === "explanation_sent" ||
(currentStatus.startsWith("explanation") && !explanation)
) {
return "explanation";
}
if (
currentStatus === "mapping_sent" ||
(currentStatus.startsWith("mapping") && !mapping)
) {
return "mapping";
}
if (
currentStatus === "diagram_sent" ||
(currentStatus.startsWith("diagram") && !diagram)
) {
return "diagram";
}
return null;
};
const renderReasoningMessage = () => {
const reasoningType = shouldShowReasoning(status);
switch (reasoningType) {
case "explanation":
return "Model is analyzing the repository structure and codebase...";
case "mapping":
return "Model is identifying component relationships and dependencies...";
case "diagram":
return "Model is planning the diagram layout and connections...";
default:
return null;
}
};
const getStatusDisplay = () => {
const reasoningType = shouldShowReasoning(status);
switch (status) {
case "explanation_sent":
case "explanation":
case "explanation_chunk":
return {
text: reasoningType
? "Model is reasoning about repository structure"
: "Explaining repository structure...",
isReasoning: !!reasoningType,
};
case "mapping_sent":
case "mapping":
case "mapping_chunk":
return {
text: reasoningType
? "Model is reasoning about component relationships"
: "Creating component mapping...",
isReasoning: !!reasoningType,
};
case "diagram_sent":
case "diagram":
case "diagram_chunk":
return {
text: reasoningType
? "Model is reasoning about diagram structure"
: "Generating diagram...",
isReasoning: !!reasoningType,
};
case "diagram_fixing":
case "diagram_fix_attempt":
case "diagram_fix_chunk":
case "diagram_fix_validating":
return {
text: message ?? "Fixing Mermaid syntax...",
isReasoning: false,
};
default:
return {
text: messages[currentMessageIndex],
isReasoning: false,
};
}
};
const statusDisplay = getStatusDisplay();
const reasoningMessage = renderReasoningMessage();
const hasFixTelemetry =
status === "diagram_fixing" ||
status === "diagram_fix_attempt" ||
status === "diagram_fix_chunk" ||
status === "diagram_fix_validating" ||
typeof fixAttempt === "number" ||
!!parserError ||
!!fixDiagramDraft;
return (
<div className="mx-auto w-full max-w-4xl p-4">
<div className="overflow-hidden rounded-xl border-2 border-purple-200 bg-purple-50/30 backdrop-blur-sm dark:border-[#2d1d4e] dark:bg-[linear-gradient(160deg,#1a1228,#150f22)]">
<div className="border-b border-purple-100 bg-purple-100/50 px-6 py-3 dark:border-[#2d1d4e] dark:bg-[#1e1832]/90">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-purple-500 dark:text-[hsl(var(--neo-button-hover))]">
{statusDisplay.text}
</span>
{statusDisplay.isReasoning && <SequentialDots />}
</div>
<div className="flex items-center gap-3 text-xs font-medium text-purple-500 dark:text-[hsl(var(--foreground))]">
{cost && <span>Estimated cost: {cost}</span>}
<div className="flex items-center gap-2">
<span className="rounded-full bg-purple-100 px-2 py-0.5 dark:bg-[#251b3a]">
Step {getStepNumber(status)}/3
</span>
<StepDots currentStep={getStepNumber(status)} />
</div>
</div>
</div>
</div>
{/* Scrollable content */}
<div ref={scrollRef} className="max-h-[400px] overflow-y-auto p-6">
<div className="flex flex-col gap-6">
{/* Only show reasoning message if we have some content */}
{reasoningMessage &&
statusDisplay.isReasoning &&
(explanation ?? mapping ?? diagram) && (
<div className="rounded-lg bg-purple-100/50 p-4 text-sm text-purple-500 dark:bg-[#1d1530] dark:text-[hsl(var(--foreground))]">
<div className="flex items-center gap-2">
<p className="font-medium">Reasoning</p>
<SequentialDots />
</div>
<p className="mt-2 leading-relaxed">{reasoningMessage}</p>
</div>
)}
{explanation && (
<div className="rounded-lg bg-white/50 p-4 text-sm text-gray-600 dark:bg-[#1a1228]/80 dark:text-[hsl(var(--foreground))]">
<p className="font-medium text-purple-500 dark:text-[hsl(var(--neo-link-hover))]">
Explanation:
</p>
<p className="mt-2 leading-relaxed">{explanation}</p>
</div>
)}
{mapping && (
<div className="rounded-lg bg-white/50 p-4 text-sm text-gray-600 dark:bg-[#1a1228]/80 dark:text-[hsl(var(--foreground))]">
<p className="font-medium text-purple-500 dark:text-[hsl(var(--neo-link-hover))]">
Mapping:
</p>
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap leading-relaxed">
{mapping}
</pre>
</div>
)}
{diagram && (
<div className="rounded-lg bg-white/50 p-4 text-sm text-gray-600 dark:bg-[#1a1228]/80 dark:text-[hsl(var(--foreground))]">
<p className="font-medium text-purple-500 dark:text-[hsl(var(--neo-link-hover))]">
Mermaid.js diagram:
</p>
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap leading-relaxed">
{diagram}
</pre>
</div>
)}
{hasFixTelemetry && (
<div className="rounded-lg border border-purple-200 bg-white/70 p-4 text-sm text-gray-600 dark:border-[#2d1d4e] dark:bg-[#1a1228]/85 dark:text-[hsl(var(--foreground))]">
<p className="font-medium text-purple-500 dark:text-[hsl(var(--neo-link-hover))]">
Syntax Repair Loop
</p>
{typeof fixAttempt === "number" &&
typeof fixMaxAttempts === "number" && (
<p className="mt-1 text-xs text-purple-500 dark:text-[hsl(var(--foreground))]">
Attempt {fixAttempt}/{fixMaxAttempts}
</p>
)}
{message && <p className="mt-2 leading-relaxed">{message}</p>}
{parserError && (
<pre className="mt-3 overflow-x-auto whitespace-pre-wrap rounded-md bg-purple-50 p-3 text-xs text-gray-700 dark:bg-[#130f22] dark:text-[hsl(var(--foreground))]">
{parserError}
</pre>
)}
{fixDiagramDraft && (
<div className="mt-3">
<p className="mb-2 text-xs font-medium text-purple-500 dark:text-[hsl(var(--neo-link-hover))]">
Candidate Mermaid fix (streaming)
</p>
<pre className="overflow-x-auto whitespace-pre-wrap rounded-md bg-purple-50 p-3 text-xs text-gray-700 dark:bg-[#130f22] dark:text-[hsl(var(--foreground))]">
{fixDiagramDraft}
</pre>
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
}
================================================
FILE: src/components/main-card.tsx
================================================
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Card } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Sparkles } from "lucide-react";
import React from "react";
import { exampleRepos, isExampleRepo } from "~/lib/exampleRepos";
import { ExportDropdown } from "./export-dropdown";
import { ChevronUp, ChevronDown } from "lucide-react";
import { Switch } from "~/components/ui/switch";
import { parseGitHubRepoUrl } from "~/features/diagram/github-url";
interface MainCardProps {
isHome?: boolean;
username?: string;
repo?: string;
onCopy?: () => void;
lastGenerated?: Date;
onExportImage?: () => void;
onRegenerate?: () => void;
zoomingEnabled?: boolean;
onZoomToggle?: () => void;
loading?: boolean;
}
export default function MainCard({
isHome = true,
username,
repo,
onCopy,
lastGenerated,
onExportImage,
onRegenerate,
zoomingEnabled,
onZoomToggle,
loading,
}: MainCardProps) {
const [repoUrl, setRepoUrl] = useState("");
const [error, setError] = useState("");
const [activeDropdown, setActiveDropdown] = useState<"export" | null>(null);
const router = useRouter();
const isExampleRepoSelected =
!isHome && !!username && !!repo && isExampleRepo(username, repo);
useEffect(() => {
if (username && repo) {
setRepoUrl(`https://github.com/${username}/${repo}`);
}
}, [username, repo]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError("");
const parsed = parseGitHubRepoUrl(repoUrl);
if (!parsed) {
setError("Please enter a valid GitHub repository URL");
return;
}
const { username, repo } = parsed;
const sanitizedUsername = encodeURIComponent(username);
const sanitizedRepo = encodeURIComponent(repo);
router.push(`/${sanitizedUsername}/${sanitizedRepo}`);
};
const handleExampleClick = (repoPath: string, e: React.MouseEvent) => {
e.preventDefault();
router.push(repoPath);
};
const handleDropdownToggle = (dropdown: "export") => {
setActiveDropdown(activeDropdown === dropdown ? null : dropdown);
};
return (
<Card className="neo-panel relative w-full max-w-3xl !bg-[hsl(var(--neo-panel))] p-4 sm:p-8">
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:gap-4">
<Input
placeholder="https://github.com/username/repo"
className="neo-input flex-1 rounded-md px-3 py-4 text-base font-bold placeholder:text-base placeholder:font-normal placeholder:text-gray-700 sm:px-4 sm:py-6 sm:text-lg sm:placeholder:text-lg dark:placeholder:text-neutral-400"
value={repoUrl}
onChange={(e) => setRepoUrl(e.target.value)}
required
/>
<Button
type="submit"
className="neo-button p-4 px-4 text-base sm:p-6 sm:px-6 sm:text-lg"
>
Diagram
</Button>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
{/* Dropdowns Container */}
{!isHome && (
<div className="space-y-4">
{/* Only show buttons and dropdowns when not loading */}
{!loading && (
<>
{/* Buttons Container */}
<div className="flex flex-col items-center gap-4 sm:flex-row sm:gap-4">
{onRegenerate && (
<button
type="button"
disabled={isExampleRepoSelected}
title={
isExampleRepoSelected
? "Regeneration is disabled for example repositories."
: undefined
}
className={`flex items-center justify-between gap-2 rounded-md border-[3px] border-black px-4 py-2 font-medium text-black transition-colors sm:max-w-[250px] dark:text-black ${
isExampleRepoSelected
? "cursor-not-allowed bg-purple-200 opacity-70 dark:bg-[#251b3a] dark:text-[hsl(var(--foreground))]"
: "bg-purple-300 hover:bg-purple-400 dark:border-[#2d1d4e] dark:bg-[hsl(var(--neo-subtle-muted))] dark:hover:bg-[hsl(var(--neo-subtle))]"
}`}
onClick={(e) => {
e.preventDefault();
setActiveDropdown(null);
if (isExampleRepoSelected) return;
onRegenerate();
}}
>
Regenerate Diagram
</button>
)}
{onCopy && lastGenerated && onExportImage && (
<div className="flex flex-col items-center justify-center gap-2">
<button
onClick={(e) => {
e.preventDefault();
handleDropdownToggle("export");
}}
className={`flex cursor-pointer items-center justify-between gap-2 rounded-md border-[3px] border-black px-4 py-2 font-medium text-black transition-colors sm:max-w-[250px] dark:text-black ${
activeDropdown === "export"
? "bg-purple-400 dark:border-[#2d1d4e] dark:bg-[hsl(var(--neo-button))]"
: "bg-purple-300 hover:bg-purple-400 dark:border-[#2d1d4e] dark:bg-[hsl(var(--neo-subtle-muted))] dark:hover:bg-[hsl(var(--neo-button-hover))]"
}`}
>
<span>Export Diagram</span>
{activeDropdown === "export" ? (
<ChevronUp size={20} />
) : (
<ChevronDown size={20} />
)}
</button>
</div>
)}
{lastGenerated && (
<>
<label
htmlFor="zoom-toggle"
className="font-medium text-black dark:text-neutral-100"
>
Enable Zoom
</label>
<Switch
id="zoom-toggle"
checked={zoomingEnabled}
onCheckedChange={onZoomToggle}
/>
</>
)}
</div>
{/* Dropdown Content */}
<div
className={`transition-all duration-200 ${
activeDropdown
? "pointer-events-auto max-h-[500px] opacity-100"
: "pointer-events-none max-h-0 opacity-0"
}`}
>
{activeDropdown === "export" && (
<ExportDropdown
onCopy={onCopy!}
lastGenerated={lastGenerated!}
onExportImage={onExportImage!}
isOpen={true}
/>
)}
</div>
</>
)}
</div>
)}
{/* Example Repositories */}
{isHome && (
<div className="space-y-2">
<div className="text-sm text-gray-700 dark:text-neutral-300 sm:text-base">
Try these example repositories:
</div>
<div className="flex flex-wrap gap-2">
{Object.entries(exampleRepos).map(([name, path]) => (
<Button
key={name}
variant="outline"
className="border-2 border-black bg-purple-400 text-sm text-black transition-transform hover:-translate-y-0.5 hover:transform hover:bg-purple-300 dark:border-black dark:bg-[hsl(var(--neo-panel-muted))] dark:text-[hsl(var(--foreground))] dark:hover:bg-[hsl(var(--neo-button))] dark:hover:text-[#0d0a19] sm:text-base"
onClick={(e) => handleExampleClick(path, e)}
>
{name}
</Button>
))}
</div>
</div>
)}
</form>
{/* Decorative Sparkle */}
<div className="absolute -bottom-8 -left-12 hidden sm:block">
<Sparkles
className="h-20 w-20 fill-sky-400 text-black dark:fill-[hsl(var(--neo-button))] dark:text-[hsl(var(--background))]"
strokeWidth={0.6}
style={{ transform: "rotate(-15deg)" }}
/>
</div>
</Card>
);
}
================================================
FILE: src/components/mermaid-diagram.test.tsx
================================================
import { render, screen } from "@testing-library/react";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import MermaidChart from "~/components/mermaid-diagram";
vi.mock("mermaid", () => ({
default: {
initialize: vi.fn(),
registerLayoutLoaders: vi.fn(),
render: vi.fn().mockResolvedValue({ svg: "<svg></svg>" }),
},
}));
describe("MermaidChart", () => {
it("renders chart container", () => {
const { container } = render(
<MermaidChart chart="flowchart TD\nA-->B" zoomingEnabled={false} />,
);
expect(container.querySelector(".mermaid")).toBeInTheDocument();
expect(screen.queryByText(/Mermaid render failed:/)).not.toBeInTheDocument();
});
});
================================================
FILE: src/components/mermaid-diagram.tsx
================================================
"use client";
import React, { useEffect, useRef, useState } from "react";
import mermaid from "mermaid";
import elkLayouts from "@mermaid-js/layout-elk";
import { useTheme } from "next-themes";
interface MermaidChartProps {
chart: string;
zoomingEnabled?: boolean;
}
type SvgPanZoomInstance = {
destroy: () => void;
};
let elkLayoutRegistered = false;
let domToJsonPatched = false;
function ensureDomNodesSerializeSafely() {
if (domToJsonPatched || typeof window === "undefined") return;
const elementProto = window.Element?.prototype;
if (!elementProto || "toJSON" in elementProto) {
domToJsonPatched = true;
return;
}
Object.defineProperty(elementProto, "toJSON", {
configurable: true,
value: function toJSON(this: Element) {
return {
tagName: this.tagName,
id: this.id || undefined,
className:
typeof this.className === "string" ? this.className : undefined,
};
},
});
domToJsonPatched = true;
}
const MermaidChart = ({ chart, zoomingEnabled = true }: MermaidChartProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const panZoomRef = useRef<SvgPanZoomInstance | null>(null);
const [renderMessage, setRenderMessage] = useState<string | null>(null);
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
useEffect(() => {
ensureDomNodesSerializeSafely();
if (!elkLayoutRegistered) {
mermaid.registerLayoutLoaders(elkLayouts);
elkLayoutRegistered = true;
}
const baseConfig = {
startOnLoad: false,
suppressErrorRendering: true,
securityLevel: "loose" as const,
theme: "base" as const,
htmlLabels: true,
flowchart: {
defaultRenderer: "elk" as const,
curve: "linear" as const,
nodeSpacing: 50,
rankSpacing: 50,
padding: 15,
},
themeVariables: isDark
? {
background: "#1f2631",
primaryColor: "#2c3544",
primaryBorderColor: "#6dd4e9",
primaryTextColor: "#e8edf5",
lineColor: "#ffd486",
secondaryColor: "#26303f",
tertiaryColor: "#323d4d",
}
: {
background: "#ffffff",
primaryColor: "#f7f7f7",
primaryBorderColor: "#000000",
primaryTextColor: "#171717",
lineColor: "#000000",
secondaryColor: "#f0f0f0",
tertiaryColor: "#f7f7f7",
},
themeCSS: `
.clickable {
transition: transform 0.2s ease;
}
.clickable:hover {
transform: scale(1.05);
cursor: pointer;
}
.clickable:hover > * {
filter: brightness(0.85);
}
`,
};
const initializeMermaid = () => {
mermaid.initialize({
...baseConfig,
});
};
const renderDiagram = async () => {
const mermaidElement = containerRef.current?.querySelector(".mermaid");
if (!(mermaidElement instanceof HTMLDivElement)) return;
setRenderMessage(null);
panZoomRef.current?.destroy();
panZoomRef.current = null;
const applyPanZoom = async () => {
const svgElement = containerRef.current?.querySelector("svg");
if (!(svgElement instanceof SVGSVGElement) || !zoomingEnabled) return;
svgElement.style.maxWidth = "none";
svgElement.style.width = "100%";
svgElement.style.height = "100%";
try {
const svgPanZoom = (await import("svg-pan-zoom")).default;
panZoomRef.current = svgPanZoom(svgElement, {
zoomEnabled: true,
controlIconsEnabled: true,
fit: true,
center: true,
minZoom: 0.1,
maxZoom: 10,
zoomScaleSensitivity: 0.3,
}) as SvgPanZoomInstance;
} catch (error) {
console.error("Failed to load svg-pan-zoom:", error);
}
};
initializeMermaid();
mermaidElement.removeAttribute("data-processed");
mermaidElement.textContent = "";
try {
const renderId = `gitdiagram-${Math.random().toString(36).slice(2)}`;
const { svg, bindFunctions } = await mermaid.render(
renderId,
chart,
mermaidElement,
);
mermaidElement.innerHTML = svg;
bindFunctions?.(mermaidElement);
await applyPanZoom();
return;
} catch (error) {
console.error("Mermaid render failed:", error);
const message =
error instanceof Error ? error.message : "Unknown Mermaid render error.";
setRenderMessage(`Mermaid render failed: ${message}`);
}
};
void renderDiagram();
return () => {
panZoomRef.current?.destroy();
panZoomRef.current = null;
};
}, [chart, zoomingEnabled, isDark]);
return (
<div
ref={containerRef}
className={`w-full max-w-full p-4 ${zoomingEnabled ? "h-[600px]" : ""}`}
>
{renderMessage && (
<div className="mb-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200">
{renderMessage}
</div>
)}
<div
key={`${chart}-${zoomingEnabled}-${resolvedTheme ?? "light"}`}
className={`mermaid h-full text-foreground ${
zoomingEnabled
? "rounded-lg border-2 border-black bg-white dark:border-[#3b4656] dark:bg-[#1f2631]"
: ""
}`}
>
{chart}
</div>
</div>
);
};
export default MermaidChart;
================================================
FILE: src/components/private-repos-dialog.tsx
================================================
"use client";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
import { Input } from "./ui/input";
import { Button } from "./ui/button";
import { useState, useEffect } from "react";
import Link from "next/link";
interface PrivateReposDialogProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (pat: string) => void;
}
export function PrivateReposDialog({
isOpen,
onClose,
onSubmit,
}: PrivateReposDialogProps) {
const [pat, setPat] = useState<string>("");
useEffect(() => {
const storedPat = localStorage.getItem("github_pat");
if (storedPat) {
setPat(storedPat);
}
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(pat);
setPat("");
};
const handleClear = () => {
localStorage.removeItem("github_pat");
setPat("");
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="neo-panel p-6 sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-xl font-bold text-black dark:text-neutral-100">
Enter GitHub Personal Access Token
</DialogTitle>
</DialogHeader>
<form
onSubmit={handleSubmit}
className="space-y-4 text-black dark:text-neutral-200"
>
<div className="text-sm">
To enable private repositories, you'll need to provide a GitHub
Personal Access Token with repo scope. The token will be stored
locally in your browser. Find out how{" "}
<Link
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens"
className="neo-link"
>
here
</Link>
.
</div>
<details className="group text-sm [&>summary:focus-visible]:outline-none">
<summary className="neo-link cursor-pointer font-medium">
Data storage disclaimer
</summary>
<div className="animate-accordion-down mt-2 space-y-2 overflow-hidden pl-2">
<p>
Take note that the diagram data will be stored in my database
(not that I would use it for anything anyways). You can also
self-host this app by following the instructions in the{" "}
<Link
href="https://github.com/ahmedkhaleel2004/gitdiagram"
className="neo-link"
>
README
</Link>
.
</p>
</div>
</details>
<Input
type="password"
placeholder="ghp_..."
value={pat}
onChange={(e) => setPat(e.target.value)}
className="neo-input flex-1 rounded-md px-3 py-2 text-base font-bold placeholder:text-base placeholder:font-normal placeholder:text-gray-700 dark:placeholder:text-neutral-400"
required
/>
<div className="flex items-center justify-between">
<button
type="button"
onClick={handleClear}
className="neo-link text-sm"
>
Clear
</button>
<div className="flex gap-3">
<Button
type="button"
onClick={onClose}
className="neo-button-muted px-4 py-2"
>
Cancel
</Button>
<Button
type="submit"
disabled={!pat.startsWith("ghp_")}
className="neo-button px-4 py-2 disabled:opacity-50"
>
Save Token
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
);
}
================================================
FILE: src/components/theme-toggle.tsx
================================================
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<button
type="button"
className="text-sm font-medium text-black transition-transform hover:translate-y-[-2px] hover:text-purple-600"
>
Dark
</button>
);
}
const isDark = resolvedTheme === "dark";
return (
<button
type="button"
onClick={() => setTheme(isDark ? "light" : "dark")}
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
className="text-sm font-medium text-black transition-transform hover:translate-y-[-2px] hover:text-purple-600 dark:text-neutral-200 dark:hover:text-[hsl(var(--neo-link-hover))]"
>
{isDark ? "Light" : "Dark"}
</button>
);
}
================================================
FILE: src/components/ui/button.tsx
================================================
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "~/lib/utils";
const buttonVariants = cva(
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
================================================
FILE: src/components/ui/card.tsx
================================================
import * as React from "react";
import { cn } from "~/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};
================================================
FILE: src/components/ui/dialog.tsx
================================================
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "~/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 duration-300 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-300 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
================================================
FILE: src/components/ui/input.tsx
================================================
import * as React from "react";
import { cn } from "~/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };
================================================
FILE: src/components/ui/progress.tsx
================================================
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "~/lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-black",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-green-500 transition-all"
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };
================================================
FILE: src/components/ui/sonner.tsx
================================================
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"toast !bg-purple-100 dark:!bg-[#1a1228] !text-black dark:!text-[hsl(var(--foreground))] !shadow-[3px_3px_0_0_#000000] dark:!shadow-[3px_3px_0_0_#0d0a19] !border-[2px] !border-black dark:!border-[#2d1d4e] !rounded-md !p-3 !flex !items-center !justify-between !gap-4",
title: "font-bold text-base m-0",
description: "text-muted-foreground dark:!text-[hsl(var(--muted-foreground))]",
actionButton:
"!bg-purple-200 dark:!bg-[hsl(var(--neo-button))] !border-[2px] !border-solid !border-black dark:!border-[#2d1d4e] !py-[14px] !px-6 !text-lg !text-black hover:!bg-purple-300 dark:hover:!bg-[hsl(var(--neo-button-hover))] !transition-colors !cursor-pointer",
cancelButton:
"text-neutral-500 underline hover:text-neutral-700 dark:text-[hsl(var(--muted-foreground))] dark:hover:text-[hsl(var(--foreground))]",
},
duration: 5000,
}}
{...props}
/>
);
};
export { Toaster };
================================================
FILE: src/components/ui/switch.tsx
================================================
"use client";
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "~/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-8 w-16 shrink-0 cursor-pointer items-center rounded-full border-2 border-black transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-purple-500 data-[state=unchecked]:bg-purple-300 dark:data-[state=checked]:bg-[hsl(var(--neo-button))] dark:data-[state=unchecked]:bg-[#251b3a]",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-6 w-6 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-[34px] data-[state=unchecked]:translate-x-[2px] dark:bg-neutral-950",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
================================================
FILE: src/components/ui/textarea.tsx
================================================
import * as React from "react";
import { cn } from "~/lib/utils";
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = "Textarea";
export { Textarea };
================================================
FILE: src/components/ui/tooltip.tsx
================================================
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "~/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md",
"duration-300 animate-in fade-in-0 zoom-in-95",
"duration-300 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
================================================
FILE: src/env.js
================================================
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars.
*/
server: {
POSTGRES_URL: z.string().url(),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
},
/**
* Specify your client-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars. To expose them to the client, prefix them with
* `NEXT_PUBLIC_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
/**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
POSTGRES_URL: process.env.POSTGRES_URL,
NODE_ENV: process.env.NODE_ENV,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
});
================================================
FILE: src/features/diagram/api.ts
================================================
import { parseSSEStreamBuffer } from "~/features/diagram/sse";
import type {
DiagramCostResponse,
DiagramStreamMessage,
StreamGenerationParams,
} from "~/features/diagram/types";
interface StreamHandlers {
onMessage: (
message: DiagramStreamMessage,
) => boolean | void | Promise<boolean | void>;
}
const getGenerateBasePath = () => {
const useLegacyBackend =
process.env.NEXT_PUBLIC_USE_LEGACY_BACKEND?.trim() === "true";
if (!useLegacyBackend) {
return "/api/generate";
}
const legacyApiBase = process.env.NEXT_PUBLIC_API_DEV_URL?.trim();
if (legacyApiBase) {
return `${legacyApiBase.replace(/\/$/, "")}/generate`;
}
return "/api/generate";
};
export async function getGenerationCost(
username: string,
repo: string,
githubPat?: string,
apiKey?: string,
): Promise<DiagramCostResponse> {
try {
const response = await fetch(`${getGenerateBasePath()}/cost`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username,
repo,
api_key: apiKey,
github_pat: githubPat,
}),
});
if (response.status === 429) {
return { error: "Rate limit exceeded. Please try again later." };
}
const data = (await response.json()) as DiagramCostResponse;
return {
cost: data.cost,
error: data.error,
error_code: data.error_code,
ok: data.ok,
};
} catch {
return { error: "Failed to get cost estimate." };
}
}
export async function streamDiagramGeneration(
params: StreamGenerationParams,
handlers: StreamHandlers,
): Promise<void> {
const response = await fetch(`${getGenerateBasePath()}/stream`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: params.username,
repo: params.repo,
api_key: params.apiKey,
github_pat: params.githubPat,
}),
});
if (!response.ok) {
throw new Error("Failed to start streaming");
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("No reader available");
}
try {
let streamBuffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
streamBuffer += new TextDecoder().decode(value);
const { messages, remainder } = parseSSEStreamBuffer(streamBuffer);
streamBuffer = remainder;
for (const message of messages) {
const shouldContinue = await handlers.onMessage(message);
if (shouldContinue === false) {
return;
}
}
}
const { messages } = parseSSEStreamBuffer(`${streamBuffer}\n\n`);
for (const message of messages) {
const shouldContinue = await handlers.onMessage(message);
if (shouldContinue === false) {
return;
}
}
} finally {
reader.releaseLock();
}
}
================================================
FILE: src/features/diagram/export.ts
================================================
export function exportMermaidSvgAsPng(svgElement: SVGSVGElement): void {
const canvas = document.createElement("canvas");
const scale = 4;
const bbox = svgElement.getBBox();
const transform = svgElement.getScreenCTM();
if (!transform) return;
const width = Math.ceil(bbox.width * transform.a);
const height = Math.ceil(bbox.height * transform.d);
canvas.width = width * scale;
canvas.height = height * scale;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const svgData = new XMLSerializer().serializeToString(svgElement);
const img = new Image();
img.onload = () => {
ctx.fillStyle = "white";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.scale(scale, scale);
ctx.drawImage(img, 0, 0, width, height);
const anchor = document.createElement("a");
anchor.download = "diagram.png";
anchor.href = canvas.toDataURL("image/png", 1.0);
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
};
img.src =
"data:image/svg+xml;base64," +
btoa(unescape(encodeURIComponent(svgData)));
}
================================================
FILE: src/features/diagram/github-url.test.ts
================================================
import { describe, expect, it } from "vitest";
import { parseGitHubRepoUrl } from "~/features/diagram/github-url";
describe("parseGitHubRepoUrl", () => {
it("parses valid repository urls", () => {
expect(parseGitHubRepoUrl("https://github.com/vercel/next.js")).toEqual({
username: "vercel",
repo: "next.js",
});
});
it("returns null for invalid urls", () => {
expect(parseGitHubRepoUrl("https://gitlab.com/vercel/next.js")).toBeNull();
expect(parseGitHubRepoUrl("not-a-url")).toBeNull();
});
});
================================================
FILE: src/features/diagram/github-url.ts
================================================
export interface ParsedGitHubRepo {
username: string;
repo: string;
}
const GITHUB_URL_PATTERN =
/^https?:\/\/github\.com\/([a-zA-Z0-9-_]+)\/([a-zA-Z0-9-_.]+)\/?$/;
export function parseGitHubRepoUrl(url: string): ParsedGitHubRepo | null {
const match = GITHUB_URL_PATTERN.exec(url.trim());
if (!match) return null;
const [, username, repo] = match;
if (!username || !repo) return null;
return { username, repo };
}
================================================
FILE: src/features/diagram/sse.test.ts
================================================
import { describe, expect, it } from "vitest";
import { parseSSEChunk, parseSSEStreamBuffer } from "~/features/diagram/sse";
describe("parseSSEChunk", () => {
it("parses valid SSE data lines", () => {
const chunk =
'data: {"status":"started","message":"Starting"}\n\n' +
'data: {"status":"diagram_chunk","chunk":"flowchart TD"}\n\n';
const messages = parseSSEChunk(chunk);
expect(messages).toHaveLength(2);
expect(messages[0]?.status).toBe("started");
expect(messages[1]?.chunk).toBe("flowchart TD");
});
it("ignores malformed lines", () => {
const chunk =
"event: custom\n" +
"data: {not-json}\n" +
'data: {"status":"complete"}\n';
const messages = parseSSEChunk(chunk);
expect(messages).toHaveLength(1);
expect(messages[0]?.status).toBe("complete");
});
it("handles events split across network boundaries", () => {
const firstHalf = 'data: {"status":"diagram_fix_attempt","message":"Attempt 1';
const secondHalf = '/3"}\n\n';
const firstPass = parseSSEStreamBuffer(firstHalf);
expect(firstPass.messages).toHaveLength(0);
const secondPass = parseSSEStreamBuffer(firstPass.remainder + secondHalf);
expect(secondPass.messages).toHaveLength(1);
expect(secondPass.messages[0]?.status).toBe("diagram_fix_attempt");
expect(secondPass.messages[0]?.message).toBe("Attempt 1/3");
});
});
================================================
FILE: src/features/diagram/sse.ts
================================================
import type { DiagramStreamMessage } from "~/features/diagram/types";
export function parseSSEChunk(chunk: string): DiagramStreamMessage[] {
const messages: DiagramStreamMessage[] = [];
const lines = chunk.split(/\r?\n/);
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
try {
const parsed = JSON.parse(payload) as DiagramStreamMessage;
messages.push(parsed);
} catch {
// Ignore malformed chunks.
}
}
return messages;
}
export function parseSSEStreamBuffer(buffer: string): {
messages: DiagramStreamMessage[];
remainder: string;
} {
const messages: DiagramStreamMessage[] = [];
const normalized = buffer.replace(/\r\n/g, "\n");
const rawEvents = normalized.split("\n\n");
const remainder = rawEvents.pop() ?? "";
for (const rawEvent of rawEvents) {
if (!rawEvent.trim()) continue;
messages.push(...parseSSEChunk(rawEvent));
}
return { messages, remainder };
}
================================================
FILE: src/features/diagram/types.ts
================================================
export type DiagramStreamStatus =
| "idle"
| "started"
| "explanation_sent"
| "explanation"
| "explanation_chunk"
| "mapping_sent"
| "mapping"
| "mapping_chunk"
| "diagram_sent"
| "diagram"
| "diagram_chunk"
| "diagram_fixing"
| "diagram_fix_attempt"
| "diagram_fix_chunk"
| "diagram_fix_validating"
| "complete"
| "error";
export interface DiagramStreamState {
status: DiagramStreamStatus;
message?: string;
explanation?: string;
mapping?: string;
diagram?: string;
error?: string;
errorCode?: string;
parserError?: string;
fixAttempt?: number;
fixMaxAttempts?: number;
fixDiagramDraft?: string;
}
export interface DiagramStreamMessage {
status: DiagramStreamStatus;
message?: string;
chunk?: string;
explanation?: string;
mapping?: string;
diagram?: string;
error?: string;
error_code?: string;
parser_error?: string;
fix_attempt?: number;
fix_max_attempts?: number;
}
export interface DiagramCostResponse {
cost?: string;
error?: string;
error_code?: string;
ok?: boolean;
}
export interface StreamGenerationParams {
username: string;
repo: string;
apiKey?: string;
githubPat?: string;
}
================================================
FILE: src/hooks/diagram/useDiagramExport.ts
================================================
import { useCallback } from "react";
import { exportMermaidSvgAsPng } from "~/features/diagram/export";
export function useDiagramExport(diagram: string) {
const handleCopy = useCallback(async () => {
await navigator.clipboard.writeText(diagram);
}, [diagram]);
const handleExportImage = useCallback(() => {
const svgElement = document.querySelector(".mermaid svg");
if (!(svgElement instanceof SVGSVGElement)) return;
exportMermaidSvgAsPng(svgElement);
}, []);
return {
handleCopy,
handleExportImage,
};
}
================================================
FILE: src/hooks/diagram/useDiagramStream.test.ts
================================================
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useDiagramStream } from "~/hooks/diagram/useDiagramStream";
vi.mock("~/features/diagram/api", () => ({
streamDiagramGeneration: vi.fn(async (_params, handlers) => {
await handlers.onMessage({ status: "started", message: "starting" });
await handlers.onMessage({ status: "explanation_chunk", chunk: "Repo details" });
await handlers.onMessage({
status: "complete",
diagram: "flowchart TD\nA-->B",
explanation: "done",
});
}),
}));
describe("useDiagramStream", () => {
it("updates state through stream lifecycle", async () => {
const onComplete = vi.fn(async () => undefined);
const onError = vi.fn();
const { result } = renderHook(() =>
useDiagramStream({
username: "acme",
repo: "demo",
onComplete,
onError,
}),
);
await act(async () => {
await result.current.runGeneration();
});
expect(result.current.state.status).toBe("complete");
expect(result.current.state.diagram).toContain("flowchart TD");
expect(onComplete).toHaveBeenCalledTimes(1);
expect(onError).not.toHaveBeenCalled();
});
});
================================================
FILE: src/hooks/diagram/useDiagramStream.ts
================================================
import { useCallback, useState } from "react";
import { streamDiagramGeneration } from "~/features/diagram/api";
import type {
DiagramStreamMessage,
DiagramStreamState,
} from "~/features/diagram/types";
interface UseDiagramStreamOptions {
username: string;
repo: string;
onComplete: (result: { diagram: string; explanation: string }) => Promise<void>;
onError: (message: string) => void;
}
export function useDiagramStream({
username,
repo,
onComplete,
onError,
}: UseDiagramStreamOptions) {
const [state, setState] = useState<DiagramStreamState>({ status: "idle" });
const handleStreamMessage = useCallback(
async (
data: DiagramStreamMessage,
buffers: {
explanation: string;
mapping: string;
diagram: string;
fixDiagramDraft: string;
},
) => {
if (data.error) {
setState({
status: "error",
error: data.error,
errorCode: data.error_code,
parserError: data.parser_error,
});
onError(data.error);
return false;
}
switch (data.status) {
case "started":
case "explanation_sent":
case "explanation":
case "mapping_sent":
case "mapping":
case "diagram_sent":
case "diagram":
case "diagram_fixing":
case "diagram_fix_attempt":
case "diagram_fix_validating":
setState((prev) => ({
...prev,
status: data.status,
message: data.message,
parserError: data.parser_error,
fixAttempt: data.fix_attempt,
fixMaxAttempts: data.fix_max_attempts,
...(data.status === "diagram_fix_attempt"
? { fixDiagramDraft: "" }
: {}),
}));
break;
case "diagram_fix_chunk":
if (data.chunk) {
buffers.fixDiagramDraft += data.chunk;
setState((prev) => ({
...prev,
status: "diagram_fix_chunk",
fixDiagramDraft: buffers.fixDiagramDraft,
fixAttempt: data.fix_attempt ?? prev.fixAttempt,
fixMaxAttempts: data.fix_max_attempts ?? prev.fixMaxAttempts,
}));
}
break;
case "explanation_chunk":
if (data.chunk) {
buffers.explanation += data.chunk;
setState((prev) => ({
...prev,
status: "explanation_chunk",
explanation: buffers.explanation,
}));
}
break;
case "mapping_chunk":
if (data.chunk) {
buffers.mapping += data.chunk;
setState((prev) => ({
...prev,
status: "mapping_chunk",
mapping: buffers.mapping,
}));
}
break;
case "diagram_chunk":
if (data.chunk) {
buffers.diagram += data.chunk;
setState((prev) => ({
...prev,
status: "diagram_chunk",
diagram: buffers.diagram,
}));
}
break;
case "complete": {
const explanation = data.explanation ?? buffers.explanation;
const diagram = data.diagram ?? buffers.diagram;
setState({
status: "complete",
explanation,
diagram,
mapping: data.mapping ?? buffers.mapping,
});
await onComplete({ explanation, diagram });
return false;
}
case "error":
setState({
status: "error",
error: data.error,
parserError: data.parser_error,
});
if (data.error) onError(data.error);
return false;
}
return true;
},
[onComplete, onError],
);
const runGeneration = useCallback(
async (githubPat?: string) => {
setState({ status: "started", message: "Starting generation process..." });
const buffers = {
explanation: "",
mapping: "",
diagram: "",
fixDiagramDraft: "",
};
await streamDiagramGeneration(
{
username,
repo,
apiKey: localStorage.getItem("openai_key") ?? undefined,
githubPat,
},
{
onMessage: (message) => handleStreamMessage(message, buffers),
},
);
},
[handleStreamMessage, repo, username],
);
return {
state,
runGeneration,
setState,
};
}
================================================
FILE: src/hooks/useDiagram.ts
================================================
import { useState, useEffect, useCallback } from "react";
import {
cacheDiagramAndExplanation,
getCachedDiagram,
} from "~/app/_actions/cache";
import { getLastGeneratedDate } from "~/app/_actions/repo";
import { getGenerationCost } from "~/features/diagram/api";
import { type DiagramStreamState } from "~/features/diagram/types";
import { useDiagramStream } from "~/hooks/diagram/useDiagramStream";
import { useDiagramExport } from "~/hooks/diagram/useDiagramExport";
import { isExampleRepo } from "~/lib/exampleRepos";
export function useDiagram(username: string, repo: string) {
const [diagram, setDiagram] = useState<string>("");
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState<boolean>(true);
const [lastGenerated, setLastGenerated] = useState<Date | undefined>();
const [cost, setCost] = useState<string>("");
const [showApiKeyDialog, setS
gitextract_484_xfnt/ ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ ├── ci.yml │ └── deploy.yml ├── .gitignore ├── .nvmrc ├── CLAUDE.md ├── LICENSE ├── README.md ├── backend/ │ ├── .python-version │ ├── Dockerfile │ ├── app/ │ │ ├── __init__.py │ │ ├── core/ │ │ │ ├── errors.py │ │ │ └── observability.py │ │ ├── main.py │ │ ├── prompts.py │ │ ├── routers/ │ │ │ └── generate.py │ │ ├── services/ │ │ │ ├── github_service.py │ │ │ ├── mermaid_service.py │ │ │ ├── model_config.py │ │ │ ├── openai_service.py │ │ │ └── pricing.py │ │ └── utils/ │ │ └── format_message.py │ ├── deploy.sh │ ├── entrypoint.sh │ ├── nginx/ │ │ ├── api.conf │ │ └── setup_nginx.sh │ ├── package.json │ ├── pyproject.toml │ ├── scripts/ │ │ └── validate_mermaid.mjs │ └── tests/ │ ├── conftest.py │ ├── test_generate_router.py │ ├── test_generate_utils.py │ └── test_pricing.py ├── components.json ├── docker-compose.yml ├── docs/ │ ├── dev-setup.md │ └── railway-backend.md ├── drizzle.config.ts ├── eslint.config.mjs ├── next.config.js ├── package.json ├── postcss.config.js ├── prettier.config.js ├── src/ │ ├── app/ │ │ ├── [username]/ │ │ │ └── [repo]/ │ │ │ ├── page.tsx │ │ │ └── repo-page-client.tsx │ │ ├── _actions/ │ │ │ ├── cache.ts │ │ │ └── repo.ts │ │ ├── api/ │ │ │ ├── generate/ │ │ │ │ ├── cost/ │ │ │ │ │ └── route.ts │ │ │ │ └── stream/ │ │ │ │ └── route.ts │ │ │ └── healthz/ │ │ │ └── route.ts │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── providers.tsx │ ├── components/ │ │ ├── action-button.tsx │ │ ├── api-key-button.tsx │ │ ├── api-key-dialog.tsx │ │ ├── copy-button.tsx │ │ ├── export-dropdown.tsx │ │ ├── footer.tsx │ │ ├── header-client.tsx │ │ ├── header.tsx │ │ ├── hero.tsx │ │ ├── loading-animation.tsx │ │ ├── loading.tsx │ │ ├── main-card.tsx │ │ ├── mermaid-diagram.test.tsx │ │ ├── mermaid-diagram.tsx │ │ ├── private-repos-dialog.tsx │ │ ├── theme-toggle.tsx │ │ └── ui/ │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── dialog.tsx │ │ ├── input.tsx │ │ ├── progress.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ ├── textarea.tsx │ │ └── tooltip.tsx │ ├── env.js │ ├── features/ │ │ └── diagram/ │ │ ├── api.ts │ │ ├── export.ts │ │ ├── github-url.test.ts │ │ ├── github-url.ts │ │ ├── sse.test.ts │ │ ├── sse.ts │ │ └── types.ts │ ├── hooks/ │ │ ├── diagram/ │ │ │ ├── useDiagramExport.ts │ │ │ ├── useDiagramStream.test.ts │ │ │ └── useDiagramStream.ts │ │ ├── useDiagram.ts │ │ └── useStarReminder.tsx │ ├── lib/ │ │ ├── exampleRepos.ts │ │ └── utils.ts │ ├── server/ │ │ ├── db/ │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ ├── generate/ │ │ │ ├── format.ts │ │ │ ├── github.ts │ │ │ ├── mermaid.test.ts │ │ │ ├── mermaid.ts │ │ │ ├── model-config.ts │ │ │ ├── openai.ts │ │ │ ├── pricing.test.ts │ │ │ ├── pricing.ts │ │ │ ├── prompts.ts │ │ │ └── types.ts │ │ └── github-stars.ts │ └── styles/ │ └── globals.css ├── start-database.sh ├── tailwind.config.ts ├── tsconfig.json ├── vitest.config.ts └── vitest.setup.ts
SYMBOL INDEX (195 symbols across 61 files)
FILE: backend/app/core/errors.py
function api_error (line 4) | def api_error(code: str, message: str, **extra):
function api_success (line 14) | def api_success(**data):
FILE: backend/app/core/observability.py
function log_event (line 13) | def log_event(event: str, **fields):
class Timer (line 25) | class Timer:
method __init__ (line 26) | def __init__(self):
method elapsed_ms (line 29) | def elapsed_ms(self) -> int:
FILE: backend/app/main.py
function root (line 39) | async def root():
function healthz (line 44) | async def healthz():
FILE: backend/app/routers/generate.py
class GenerateRequest (line 35) | class GenerateRequest(BaseModel):
function _sse_message (line 42) | def _sse_message(payload: dict[str, Any]) -> str:
function _strip_mermaid_code_fences (line 46) | def _strip_mermaid_code_fences(text: str) -> str:
function _extract_component_mapping (line 50) | def _extract_component_mapping(response: str) -> str:
function process_click_events (line 60) | def process_click_events(diagram: str, username: str, repo: str, branch:...
function _parse_request_payload (line 74) | def _parse_request_payload(payload: Any) -> tuple[GenerateRequest | None...
function _get_github_data (line 82) | def _get_github_data(username: str, repo: str, github_pat: str | None):
function _estimate_repo_input_tokens (line 87) | async def _estimate_repo_input_tokens(
function get_generation_cost (line 109) | async def get_generation_cost(request: Request):
function generate_stream (line 177) | async def generate_stream(request: Request):
FILE: backend/app/services/github_service.py
class GithubData (line 43) | class GithubData:
function _should_include_file (line 49) | def _should_include_file(path: str) -> bool:
function _fetch_json (line 54) | def _fetch_json(url: str, headers: dict[str, str], not_found_message: st...
class GitHubService (line 63) | class GitHubService:
method __init__ (line 64) | def __init__(self, pat: str | None = None):
method _normalize_private_key (line 76) | def _normalize_private_key(self) -> str:
method _can_use_app_auth (line 82) | def _can_use_app_auth(self) -> bool:
method _generate_jwt (line 85) | def _generate_jwt(self) -> str:
method _get_installation_token (line 96) | def _get_installation_token(self) -> str:
method _get_headers (line 136) | def _get_headers(self) -> dict[str, str]:
method get_default_branch (line 153) | def get_default_branch(self, username: str, repo: str) -> str:
method get_github_file_paths_as_list (line 161) | def get_github_file_paths_as_list(self, username: str, repo: str, bran...
method get_github_readme (line 178) | def get_github_readme(self, username: str, repo: str) -> str:
method get_github_data (line 193) | def get_github_data(self, username: str, repo: str) -> GithubData:
FILE: backend/app/services/mermaid_service.py
class MermaidValidationResult (line 9) | class MermaidValidationResult:
function normalize_parser_message (line 17) | def normalize_parser_message(message: str | None) -> str:
function validate_mermaid_syntax (line 27) | def validate_mermaid_syntax(diagram: str) -> MermaidValidationResult:
function format_validation_feedback (line 71) | def format_validation_feedback(result: MermaidValidationResult) -> str:
FILE: backend/app/services/model_config.py
function get_model (line 8) | def get_model() -> str:
FILE: backend/app/services/openai_service.py
class OpenAIService (line 17) | class OpenAIService:
method __init__ (line 18) | def __init__(self):
method _resolve_api_key (line 21) | def _resolve_api_key(self, override_api_key: str | None = None) -> str:
method estimate_tokens (line 30) | def estimate_tokens(text: str) -> int:
method _build_input (line 35) | def _build_input(system_prompt: str, user_prompt: str) -> list[dict]:
method _create_client (line 42) | def _create_client(api_key: str) -> AsyncOpenAI:
method stream_completion (line 50) | async def stream_completion(
method count_input_tokens (line 89) | async def count_input_tokens(
FILE: backend/app/services/pricing.py
class ModelPricing (line 9) | class ModelPricing:
function _strip_date_snapshot_suffix (line 36) | def _strip_date_snapshot_suffix(model: str) -> str:
function resolve_pricing_model (line 42) | def resolve_pricing_model(model: str) -> str:
function estimate_text_token_cost_usd (line 81) | def estimate_text_token_cost_usd(
FILE: backend/app/utils/format_message.py
function format_user_message (line 1) | def format_user_message(data: dict[str, str | None]) -> str:
FILE: backend/scripts/validate_mermaid.mjs
function ensureDomPurifyPatched (line 11) | function ensureDomPurifyPatched() {
function getMermaid (line 29) | async function getMermaid() {
function ensureMermaidInitialized (line 37) | async function ensureMermaidInitialized() {
function readStdin (line 48) | async function readStdin() {
function normalizeError (line 56) | function normalizeError(error) {
function main (line 66) | async function main() {
FILE: backend/tests/test_generate_router.py
function test_healthz_ok (line 13) | def test_healthz_ok():
function test_generate_cost_success (line 19) | def test_generate_cost_success(monkeypatch):
function test_generate_cost_error (line 51) | def test_generate_cost_error(monkeypatch):
function test_generate_stream_event_order_with_fix_loop (line 68) | def test_generate_stream_event_order_with_fix_loop(monkeypatch):
function test_modify_route_removed (line 137) | def test_modify_route_removed():
FILE: backend/tests/test_generate_utils.py
function test_process_click_events_builds_blob_and_tree_links (line 4) | def test_process_click_events_builds_blob_and_tree_links():
FILE: backend/tests/test_pricing.py
function test_resolve_pricing_model_keeps_gpt_5_4_mini_on_its_own_tier (line 4) | def test_resolve_pricing_model_keeps_gpt_5_4_mini_on_its_own_tier():
function test_estimate_text_token_cost_uses_gpt_5_4_mini_pricing (line 9) | def test_estimate_text_token_cost_uses_gpt_5_4_mini_pricing():
FILE: next.config.js
method rewrites (line 10) | async rewrites() {
FILE: src/app/[username]/[repo]/page.tsx
type RepoPageProps (line 4) | type RepoPageProps = {
function generateMetadata (line 8) | async function generateMetadata({
function Repo (line 18) | async function Repo({ params }: RepoPageProps) {
FILE: src/app/[username]/[repo]/repo-page-client.tsx
type RepoPageClientProps (line 12) | type RepoPageClientProps = {
function RepoPageClient (line 17) | function RepoPageClient({ username, repo }: RepoPageClientProps) {
FILE: src/app/_actions/cache.ts
function getCachedDiagram (line 8) | async function getCachedDiagram(username: string, repo: string) {
function getCachedExplanation (line 25) | async function getCachedExplanation(username: string, repo: string) {
function cacheDiagramAndExplanation (line 42) | async function cacheDiagramAndExplanation(
function getDiagramStats (line 73) | async function getDiagramStats() {
FILE: src/app/_actions/repo.ts
function getLastGeneratedDate (line 7) | async function getLastGeneratedDate(username: string, repo: string) {
FILE: src/app/api/generate/cost/route.ts
constant MULTI_STAGE_INPUT_MULTIPLIER (line 14) | const MULTI_STAGE_INPUT_MULTIPLIER = 2;
constant INPUT_OVERHEAD_TOKENS (line 15) | const INPUT_OVERHEAD_TOKENS = 3000;
constant ESTIMATED_OUTPUT_TOKENS (line 16) | const ESTIMATED_OUTPUT_TOKENS = 8000;
function estimateRepoInputTokens (line 18) | async function estimateRepoInputTokens(
function POST (line 40) | async function POST(request: Request) {
FILE: src/app/api/generate/stream/route.ts
constant MAX_MERMAID_FIX_ATTEMPTS (line 29) | const MAX_MERMAID_FIX_ATTEMPTS = 3;
function sleep (line 31) | function sleep(ms: number) {
function estimateRepoTokenCount (line 35) | async function estimateRepoTokenCount(
function POST (line 57) | async function POST(request: Request) {
FILE: src/app/api/healthz/route.ts
function GET (line 6) | async function GET() {
FILE: src/app/layout.tsx
function RootLayout (line 75) | function RootLayout({
FILE: src/app/page.tsx
function HomePage (line 11) | function HomePage() {
FILE: src/app/providers.tsx
function CSPostHogProvider (line 25) | function CSPostHogProvider({ children }: { children: React.ReactNode }) {
FILE: src/components/action-button.tsx
type ActionButtonProps (line 10) | interface ActionButtonProps {
function ActionButton (line 18) | function ActionButton({
FILE: src/components/api-key-button.tsx
type ApiKeyButtonProps (line 4) | interface ApiKeyButtonProps {
function ApiKeyButton (line 8) | function ApiKeyButton({ onClick }: ApiKeyButtonProps) {
FILE: src/components/api-key-dialog.tsx
type ApiKeyDialogProps (line 9) | interface ApiKeyDialogProps {
function ApiKeyDialog (line 15) | function ApiKeyDialog({ isOpen, onClose, onSubmit }: ApiKeyDialogProps) {
FILE: src/components/copy-button.tsx
type CopyButtonProps (line 11) | interface CopyButtonProps {
function CopyButton (line 15) | function CopyButton({ onClick }: CopyButtonProps) {
FILE: src/components/export-dropdown.tsx
type ExportDropdownProps (line 5) | interface ExportDropdownProps {
function ExportDropdown (line 12) | function ExportDropdown({
FILE: src/components/footer.tsx
function Footer (line 4) | function Footer() {
FILE: src/components/header-client.tsx
type HeaderClientProps (line 10) | interface HeaderClientProps {
function formatStarCount (line 19) | function formatStarCount(count: number) {
function HeaderClient (line 23) | function HeaderClient({ starCount }: HeaderClientProps) {
FILE: src/components/header.tsx
function Header (line 4) | async function Header() {
FILE: src/components/loading.tsx
type LoadingProps (line 26) | interface LoadingProps {
function Loading (line 75) | function Loading({
FILE: src/components/main-card.tsx
type MainCardProps (line 16) | interface MainCardProps {
function MainCard (line 29) | function MainCard({
FILE: src/components/mermaid-diagram.tsx
type MermaidChartProps (line 8) | interface MermaidChartProps {
type SvgPanZoomInstance (line 13) | type SvgPanZoomInstance = {
function ensureDomNodesSerializeSafely (line 20) | function ensureDomNodesSerializeSafely() {
FILE: src/components/private-repos-dialog.tsx
type PrivateReposDialogProps (line 9) | interface PrivateReposDialogProps {
function PrivateReposDialog (line 15) | function PrivateReposDialog({
FILE: src/components/theme-toggle.tsx
function ThemeToggle (line 6) | function ThemeToggle() {
FILE: src/components/ui/button.tsx
type ButtonProps (line 36) | interface ButtonProps
FILE: src/components/ui/sonner.tsx
type ToasterProps (line 6) | type ToasterProps = React.ComponentProps<typeof Sonner>;
FILE: src/features/diagram/api.ts
type StreamHandlers (line 8) | interface StreamHandlers {
function getGenerationCost (line 28) | async function getGenerationCost(
function streamDiagramGeneration (line 64) | async function streamDiagramGeneration(
FILE: src/features/diagram/export.ts
function exportMermaidSvgAsPng (line 1) | function exportMermaidSvgAsPng(svgElement: SVGSVGElement): void {
FILE: src/features/diagram/github-url.ts
type ParsedGitHubRepo (line 1) | interface ParsedGitHubRepo {
constant GITHUB_URL_PATTERN (line 6) | const GITHUB_URL_PATTERN =
function parseGitHubRepoUrl (line 9) | function parseGitHubRepoUrl(url: string): ParsedGitHubRepo | null {
FILE: src/features/diagram/sse.ts
function parseSSEChunk (line 3) | function parseSSEChunk(chunk: string): DiagramStreamMessage[] {
function parseSSEStreamBuffer (line 22) | function parseSSEStreamBuffer(buffer: string): {
FILE: src/features/diagram/types.ts
type DiagramStreamStatus (line 1) | type DiagramStreamStatus =
type DiagramStreamState (line 20) | interface DiagramStreamState {
type DiagramStreamMessage (line 34) | interface DiagramStreamMessage {
type DiagramCostResponse (line 48) | interface DiagramCostResponse {
type StreamGenerationParams (line 55) | interface StreamGenerationParams {
FILE: src/hooks/diagram/useDiagramExport.ts
function useDiagramExport (line 5) | function useDiagramExport(diagram: string) {
FILE: src/hooks/diagram/useDiagramStream.ts
type UseDiagramStreamOptions (line 9) | interface UseDiagramStreamOptions {
function useDiagramStream (line 16) | function useDiagramStream({
FILE: src/hooks/useDiagram.ts
function useDiagram (line 14) | function useDiagram(username: string, repo: string) {
FILE: src/hooks/useStarReminder.tsx
function useStarReminder (line 6) | function useStarReminder() {
FILE: src/lib/exampleRepos.ts
function normalizePathSegment (line 9) | function normalizePathSegment(value: string) {
function isExampleRepo (line 17) | function isExampleRepo(username: string, repo: string) {
FILE: src/lib/utils.ts
function cn (line 4) | function cn(...inputs: ClassValue[]) {
FILE: src/server/db/index.ts
type DrizzleDatabase (line 13) | type DrizzleDatabase =
FILE: src/server/generate/format.ts
type TaggedValues (line 1) | type TaggedValues = Record<string, string | undefined>;
function toTaggedMessage (line 3) | function toTaggedMessage(values: TaggedValues): string {
function processClickEvents (line 10) | function processClickEvents(
function extractComponentMapping (line 28) | function extractComponentMapping(response: string): string {
function stripMermaidCodeFences (line 41) | function stripMermaidCodeFences(text: string): string {
FILE: src/server/generate/github.ts
type GitHubRepoResponse (line 1) | interface GitHubRepoResponse {
type GitHubTreeItem (line 5) | interface GitHubTreeItem {
type GitHubTreeResponse (line 9) | interface GitHubTreeResponse {
type GitHubReadmeResponse (line 13) | interface GitHubReadmeResponse {
type GithubData (line 18) | interface GithubData {
constant EXCLUDED_PATTERNS (line 24) | const EXCLUDED_PATTERNS = [
function shouldIncludeFile (line 54) | function shouldIncludeFile(path: string): boolean {
function createHeaders (line 59) | function createHeaders(githubPat?: string): HeadersInit {
function fetchJson (line 74) | async function fetchJson<T>(
function getDefaultBranch (line 97) | async function getDefaultBranch(
function getFileTree (line 111) | async function getFileTree(
function getReadme (line 137) | async function getReadme(
function getGithubData (line 159) | async function getGithubData(
FILE: src/server/generate/mermaid.ts
type DomPurifyLike (line 11) | interface DomPurifyLike {
function ensureDomPurifyPatched (line 17) | function ensureDomPurifyPatched() {
function getMermaid (line 35) | async function getMermaid() {
function ensureMermaidInitialized (line 44) | async function ensureMermaidInitialized() {
function normalizeParserMessage (line 56) | function normalizeParserMessage(message?: string): string {
type MermaidErrorHash (line 71) | interface MermaidErrorHash {
type MermaidParserError (line 77) | interface MermaidParserError extends Error {
type MermaidValidationResult (line 81) | interface MermaidValidationResult {
function validateMermaidSyntax (line 89) | async function validateMermaidSyntax(
function formatValidationFeedback (line 108) | function formatValidationFeedback(result: MermaidValidationResult): stri...
FILE: src/server/generate/model-config.ts
constant DEFAULT_MODEL (line 1) | const DEFAULT_MODEL = "gpt-5.4-mini";
function readEnvValue (line 3) | function readEnvValue(name: string): string | undefined {
function getModel (line 8) | function getModel(): string {
FILE: src/server/generate/openai.ts
type ReasoningEffort (line 3) | type ReasoningEffort = "low" | "medium" | "high";
function resolveApiKey (line 5) | function resolveApiKey(overrideApiKey?: string): string {
function estimateTokens (line 15) | function estimateTokens(text: string): number {
type StreamCompletionParams (line 20) | interface StreamCompletionParams {
type CountInputTokensParams (line 65) | interface CountInputTokensParams {
function countInputTokens (line 73) | async function countInputTokens({
FILE: src/server/generate/pricing.ts
type ModelPricing (line 1) | interface ModelPricing {
constant DEFAULT_PRICING_MODEL (line 6) | const DEFAULT_PRICING_MODEL = "gpt-5.4-mini";
constant MODEL_PRICING (line 8) | const MODEL_PRICING: Record<string, ModelPricing> = {
constant DEFAULT_PRICING (line 26) | const DEFAULT_PRICING = MODEL_PRICING[DEFAULT_PRICING_MODEL] as ModelPri...
function normalizeModelId (line 28) | function normalizeModelId(model: string): string {
function stripDateSnapshotSuffix (line 32) | function stripDateSnapshotSuffix(model: string): string {
function resolvePricingModel (line 36) | function resolvePricingModel(model: string): string {
function estimateTextTokenCostUsd (line 60) | function estimateTextTokenCostUsd(
FILE: src/server/generate/prompts.ts
constant SYSTEM_FIRST_PROMPT (line 1) | const SYSTEM_FIRST_PROMPT = `
constant SYSTEM_SECOND_PROMPT (line 45) | const SYSTEM_SECOND_PROMPT = `
constant SYSTEM_THIRD_PROMPT (line 71) | const SYSTEM_THIRD_PROMPT = `
constant SYSTEM_FIX_MERMAID_PROMPT (line 161) | const SYSTEM_FIX_MERMAID_PROMPT = `
FILE: src/server/generate/types.ts
type GenerateRequest (line 10) | type GenerateRequest = z.infer<typeof generateRequestSchema>;
function sseMessage (line 12) | function sseMessage(payload: Record<string, unknown>): string {
FILE: src/server/github-stars.ts
type GitHubRepoResponse (line 3) | interface GitHubRepoResponse {
constant GITHUB_REPO_URL (line 7) | const GITHUB_REPO_URL =
constant GITHUB_API_VERSION (line 9) | const GITHUB_API_VERSION = "2022-11-28";
constant STAR_COUNT_REVALIDATE_SECONDS (line 10) | const STAR_COUNT_REVALIDATE_SECONDS = 60 * 30;
function createHeaders (line 12) | function createHeaders(): HeadersInit {
function getStarCount (line 29) | async function getStarCount() {
Condensed preview — 112 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (254K chars).
[
{
"path": ".github/FUNDING.yml",
"chars": 899,
"preview": "# These are supported funding model platforms\n\ngithub: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [u"
},
{
"path": ".github/workflows/ci.yml",
"chars": 1273,
"preview": "name: CI\n\non:\n pull_request:\n push:\n branches:\n - main\n\njobs:\n frontend:\n runs-on: ubuntu-latest\n env:\n"
},
{
"path": ".github/workflows/deploy.yml",
"chars": 1225,
"preview": "name: Deploy to EC2\n\non:\n # Disabled for automatic deploys after migrating to Next.js/Vercel backend.\n # Kept as legac"
},
{
"path": ".gitignore",
"chars": 686,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
},
{
"path": ".nvmrc",
"chars": 3,
"preview": "22\n"
},
{
"path": "CLAUDE.md",
"chars": 4207,
"preview": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## "
},
{
"path": "LICENSE",
"chars": 1070,
"preview": "MIT License\n\nCopyright (c) 2024 Ahmed Khaleel\n\nPermission is hereby granted, free of charge, to any person obtaining a c"
},
{
"path": "README.md",
"chars": 4470,
"preview": "[](https://gitdiagram.com/)\n\n:\n payload = {\n \"ok\": Fals"
},
{
"path": "backend/app/core/observability.py",
"chars": 618,
"preview": "from __future__ import annotations\n\nimport json\nimport logging\nimport time\n\n\nlogger = logging.getLogger(\"gitdiagram.api\""
},
{
"path": "backend/app/main.py",
"chars": 1093,
"preview": "import os\n\nfrom api_analytics.fastapi import Analytics\nfrom fastapi import FastAPI\nfrom fastapi.middleware.cors import C"
},
{
"path": "backend/app/prompts.py",
"chars": 13407,
"preview": "# This is our processing. This is where GitDiagram makes the magic happen\n# There is a lot of DETAIL we need to extract "
},
{
"path": "backend/app/routers/generate.py",
"chars": 16139,
"preview": "from __future__ import annotations\n\nimport asyncio\nimport json\nimport re\nfrom typing import Any\n\nfrom fastapi import API"
},
{
"path": "backend/app/services/github_service.py",
"chars": 6727,
"preview": "from __future__ import annotations\n\nimport base64\nimport os\nfrom datetime import UTC, datetime, timedelta\nfrom dataclass"
},
{
"path": "backend/app/services/mermaid_service.py",
"chars": 2486,
"preview": "from __future__ import annotations\n\nimport json\nimport subprocess\nfrom dataclasses import dataclass\n\n\n@dataclass(frozen="
},
{
"path": "backend/app/services/model_config.py",
"chars": 188,
"preview": "from __future__ import annotations\n\nimport os\n\nDEFAULT_MODEL = \"gpt-5.4-mini\"\n\n\ndef get_model() -> str:\n model = os.g"
},
{
"path": "backend/app/services/openai_service.py",
"chars": 3813,
"preview": "from __future__ import annotations\n\nfrom typing import AsyncGenerator, Literal\nimport math\nimport os\n\nfrom dotenv import"
},
{
"path": "backend/app/services/pricing.py",
"chars": 3395,
"preview": "from __future__ import annotations\n\nfrom dataclasses import dataclass\n\nDEFAULT_PRICING_MODEL = \"gpt-5.4-mini\"\n\n\n@datacla"
},
{
"path": "backend/app/utils/format_message.py",
"chars": 242,
"preview": "def format_user_message(data: dict[str, str | None]) -> str:\n parts: list[str] = []\n for key, value in data.items("
},
{
"path": "backend/deploy.sh",
"chars": 503,
"preview": "#!/bin/bash\n\n# Exit on any error\nset -e\n\n# Navigate to project directory\ncd ~/gitdiagram\n\n# Pull latest changes\ngit pull"
},
{
"path": "backend/entrypoint.sh",
"chars": 672,
"preview": "#!/bin/bash\n\nset -euo pipefail\n\nENVIRONMENT=\"${ENVIRONMENT:-production}\"\nHOST=\"${HOST:-0.0.0.0}\"\nPORT=\"${PORT:-8000}\"\nWE"
},
{
"path": "backend/nginx/api.conf",
"chars": 1616,
"preview": "server {\n server_name api.gitdiagram.com;\n\n # Block requests with no valid Host header\n if ($host !~ ^(api.gitd"
},
{
"path": "backend/nginx/setup_nginx.sh",
"chars": 534,
"preview": "#!/bin/bash\n\n# Exit on any error\nset -e\n\n# Check if running as root\nif [ \"$EUID\" -ne 0 ]; then \n echo \"Please run as "
},
{
"path": "backend/package.json",
"chars": 191,
"preview": "{\n \"name\": \"gitdiagram-backend-mermaid-validator\",\n \"private\": true,\n \"type\": \"module\",\n \"dependencies\": {\n \"domp"
},
{
"path": "backend/pyproject.toml",
"chars": 543,
"preview": "[project]\nname = \"gitdiagram-backend\"\nversion = \"0.1.0\"\ndescription = \"FastAPI backend for GitDiagram\"\nrequires-python ="
},
{
"path": "backend/scripts/validate_mermaid.mjs",
"chars": 2041,
"preview": "import { createRequire } from \"node:module\";\nimport { stdin, stdout, stderr } from \"node:process\";\n\nimport DOMPurify fro"
},
{
"path": "backend/tests/conftest.py",
"chars": 168,
"preview": "from pathlib import Path\nimport sys\n\nBACKEND_ROOT = Path(__file__).resolve().parents[1]\nif str(BACKEND_ROOT) not in sys."
},
{
"path": "backend/tests/test_generate_router.py",
"chars": 4657,
"preview": "import json\nfrom types import SimpleNamespace\n\nfrom fastapi.testclient import TestClient\n\nfrom app.main import app\nfrom "
},
{
"path": "backend/tests/test_generate_utils.py",
"chars": 411,
"preview": "from app.routers.generate import process_click_events\n\n\ndef test_process_click_events_builds_blob_and_tree_links():\n "
},
{
"path": "backend/tests/test_pricing.py",
"chars": 706,
"preview": "from app.services.pricing import estimate_text_token_cost_usd, resolve_pricing_model\n\n\ndef test_resolve_pricing_model_ke"
},
{
"path": "components.json",
"chars": 450,
"preview": "{\n \"$schema\": \"https://ui.shadcn.com/schema.json\",\n \"style\": \"default\",\n \"rsc\": true,\n \"tsx\": true,\n \"tailwind\": {\n"
},
{
"path": "docker-compose.yml",
"chars": 306,
"preview": "services:\n api:\n build: \n context: ./backend\n dockerfile: Dockerfile\n ports:\n - \"8000:8000\"\n vo"
},
{
"path": "docs/dev-setup.md",
"chars": 2150,
"preview": "# Local Development Setup\n\nThis project runs generation primarily through the FastAPI backend in `backend/` (Railway in "
},
{
"path": "docs/railway-backend.md",
"chars": 2347,
"preview": "# Railway Backend Deploy Guide\n\nThis guide deploys the production FastAPI backend from this monorepo.\n\n## 1) Prerequisit"
},
{
"path": "drizzle.config.ts",
"chars": 260,
"preview": "import { type Config } from \"drizzle-kit\";\n\nimport { env } from \"~/env\";\n\nexport default {\n schema: \"./src/server/db/sc"
},
{
"path": "eslint.config.mjs",
"chars": 1444,
"preview": "import nextCoreVitals from \"eslint-config-next/core-web-vitals\";\nimport nextTypescript from \"eslint-config-next/typescri"
},
{
"path": "next.config.js",
"chars": 649,
"preview": "/**\n * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful\n * for Docker b"
},
{
"path": "package.json",
"chars": 2948,
"preview": "{\n \"name\": \"gitdiagram\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"build\": \"next"
},
{
"path": "postcss.config.js",
"chars": 94,
"preview": "const config = {\n plugins: {\n \"@tailwindcss/postcss\": {},\n },\n};\n\nexport default config;\n"
},
{
"path": "prettier.config.js",
"chars": 183,
"preview": "/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */\nconst config = {\n plugin"
},
{
"path": "src/app/[username]/[repo]/page.tsx",
"chars": 625,
"preview": "import type { Metadata } from \"next\";\nimport RepoPageClient from \"./repo-page-client\";\n\ntype RepoPageProps = {\n params:"
},
{
"path": "src/app/[username]/[repo]/repo-page-client.tsx",
"chars": 3361,
"preview": "\"use client\";\n\nimport { useState } from \"react\";\nimport MainCard from \"~/components/main-card\";\nimport Loading from \"~/c"
},
{
"path": "src/app/_actions/cache.ts",
"chars": 2098,
"preview": "\"use server\";\n\nimport { db } from \"~/server/db\";\nimport { eq, and } from \"drizzle-orm\";\nimport { diagramCache } from \"~/"
},
{
"path": "src/app/_actions/repo.ts",
"chars": 411,
"preview": "\"use server\";\n\nimport { db } from \"~/server/db\";\nimport { eq, and } from \"drizzle-orm\";\nimport { diagramCache } from \"~/"
},
{
"path": "src/app/api/generate/cost/route.ts",
"chars": 2788,
"preview": "import { NextResponse } from \"next/server\";\n\nimport { toTaggedMessage } from \"~/server/generate/format\";\nimport { getGit"
},
{
"path": "src/app/api/generate/stream/route.ts",
"chars": 9510,
"preview": "import { getModel } from \"~/server/generate/model-config\";\nimport {\n extractComponentMapping,\n processClickEvents,\n s"
},
{
"path": "src/app/api/healthz/route.ts",
"chars": 207,
"preview": "import { NextResponse } from \"next/server\";\n\nexport const runtime = \"nodejs\";\nexport const dynamic = \"force-dynamic\";\n\ne"
},
{
"path": "src/app/layout.tsx",
"chars": 2373,
"preview": "import \"~/styles/globals.css\";\n\nimport { GeistSans } from \"geist/font/sans\";\nimport { type Metadata } from \"next\";\nimpor"
},
{
"path": "src/app/page.tsx",
"chars": 1170,
"preview": "import MainCard from \"~/components/main-card\";\nimport Hero from \"~/components/hero\";\nimport type { Metadata } from \"next"
},
{
"path": "src/app/providers.tsx",
"chars": 1051,
"preview": "// app/providers.js\n\"use client\";\nimport posthog from \"posthog-js\";\nimport { PostHogProvider } from \"posthog-js/react\";\n"
},
{
"path": "src/components/action-button.tsx",
"chars": 1066,
"preview": "import { Button } from \"~/components/ui/button\";\nimport type { LucideIcon } from \"lucide-react\";\nimport {\n Tooltip,\n T"
},
{
"path": "src/components/api-key-button.tsx",
"chars": 369,
"preview": "import { Key } from \"lucide-react\";\nimport { Button } from \"./ui/button\";\n\ninterface ApiKeyButtonProps {\n onClick: () ="
},
{
"path": "src/components/api-key-dialog.tsx",
"chars": 4092,
"preview": "\"use client\";\n\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from \"./ui/dialog\";\nimport { Input } from \"./"
},
{
"path": "src/components/copy-button.tsx",
"chars": 1430,
"preview": "import React, { useState } from \"react\";\nimport { Button } from \"~/components/ui/button\";\nimport { FileText, Check } fro"
},
{
"path": "src/components/export-dropdown.tsx",
"chars": 924,
"preview": "import { CopyButton } from \"./copy-button\";\nimport { Image } from \"lucide-react\";\nimport { ActionButton } from \"./action"
},
{
"path": "src/components/footer.tsx",
"chars": 595,
"preview": "import React from \"react\";\nimport Link from \"next/link\";\n\nexport function Footer() {\n return (\n <footer className=\"m"
},
{
"path": "src/components/header-client.tsx",
"chars": 4106,
"preview": "\"use client\";\n\nimport { useState } from \"react\";\nimport Link from \"next/link\";\nimport { FaGithub } from \"react-icons/fa\""
},
{
"path": "src/components/header.tsx",
"chars": 230,
"preview": "import { getStarCount } from \"~/server/github-stars\";\nimport { HeaderClient } from \"./header-client\";\n\nexport async func"
},
{
"path": "src/components/hero.tsx",
"chars": 3045,
"preview": "import React from \"react\";\n\nconst Hero = () => {\n return (\n <div className=\"relative mx-auto flex w-full flex-col it"
},
{
"path": "src/components/loading-animation.tsx",
"chars": 337,
"preview": "\"use client\";\n\nimport { trio } from \"ldrs\";\nimport { useTheme } from \"next-themes\";\n\ntrio.register();\n\nconst LoadingAnim"
},
{
"path": "src/components/loading.tsx",
"chars": 11171,
"preview": "\"use client\";\n\nimport { useEffect, useState, useRef } from \"react\";\nimport type { DiagramStreamStatus } from \"~/features"
},
{
"path": "src/components/main-card.tsx",
"chars": 8794,
"preview": "\"use client\";\n\nimport { useState, useEffect } from \"react\";\nimport { useRouter } from \"next/navigation\";\nimport { Card }"
},
{
"path": "src/components/mermaid-diagram.test.tsx",
"chars": 721,
"preview": "import { render, screen } from \"@testing-library/react\";\nimport React from \"react\";\nimport { describe, expect, it, vi } "
},
{
"path": "src/components/mermaid-diagram.tsx",
"chars": 5642,
"preview": "\"use client\";\n\nimport React, { useEffect, useRef, useState } from \"react\";\nimport mermaid from \"mermaid\";\nimport elkLayo"
},
{
"path": "src/components/private-repos-dialog.tsx",
"chars": 3865,
"preview": "\"use client\";\n\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from \"./ui/dialog\";\nimport { Input } from \"./"
},
{
"path": "src/components/theme-toggle.tsx",
"chars": 986,
"preview": "\"use client\";\n\nimport { useTheme } from \"next-themes\";\nimport { useEffect, useState } from \"react\";\n\nexport function The"
},
{
"path": "src/components/ui/button.tsx",
"chars": 1957,
"preview": "import * as React from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"cla"
},
{
"path": "src/components/ui/card.tsx",
"chars": 1888,
"preview": "import * as React from \"react\";\n\nimport { cn } from \"~/lib/utils\";\n\nconst Card = React.forwardRef<\n HTMLDivElement,\n R"
},
{
"path": "src/components/ui/dialog.tsx",
"chars": 3720,
"preview": "\"use client\";\n\nimport * as React from \"react\";\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\";\nimport { X } f"
},
{
"path": "src/components/ui/input.tsx",
"chars": 747,
"preview": "import * as React from \"react\";\n\nimport { cn } from \"~/lib/utils\";\n\nconst Input = React.forwardRef<HTMLInputElement, Rea"
},
{
"path": "src/components/ui/progress.tsx",
"chars": 797,
"preview": "\"use client\";\n\nimport * as React from \"react\";\nimport * as ProgressPrimitive from \"@radix-ui/react-progress\";\n\nimport { "
},
{
"path": "src/components/ui/sonner.tsx",
"chars": 1388,
"preview": "\"use client\";\n\nimport { useTheme } from \"next-themes\";\nimport { Toaster as Sonner } from \"sonner\";\n\ntype ToasterProps = "
},
{
"path": "src/components/ui/switch.tsx",
"chars": 1295,
"preview": "\"use client\";\n\nimport * as React from \"react\";\nimport * as SwitchPrimitives from \"@radix-ui/react-switch\";\n\nimport { cn "
},
{
"path": "src/components/ui/textarea.tsx",
"chars": 644,
"preview": "import * as React from \"react\";\n\nimport { cn } from \"~/lib/utils\";\n\nconst Textarea = React.forwardRef<\n HTMLTextAreaEle"
},
{
"path": "src/components/ui/tooltip.tsx",
"chars": 1223,
"preview": "\"use client\";\n\nimport * as React from \"react\";\nimport * as TooltipPrimitive from \"@radix-ui/react-tooltip\";\n\nimport { cn"
},
{
"path": "src/env.js",
"chars": 1389,
"preview": "import { createEnv } from \"@t3-oss/env-nextjs\";\nimport { z } from \"zod\";\n\nexport const env = createEnv({\n /**\n * Spec"
},
{
"path": "src/features/diagram/api.ts",
"chars": 2918,
"preview": "import { parseSSEStreamBuffer } from \"~/features/diagram/sse\";\nimport type {\n DiagramCostResponse,\n DiagramStreamMessa"
},
{
"path": "src/features/diagram/export.ts",
"chars": 1109,
"preview": "export function exportMermaidSvgAsPng(svgElement: SVGSVGElement): void {\n const canvas = document.createElement(\"canvas"
},
{
"path": "src/features/diagram/github-url.test.ts",
"chars": 535,
"preview": "import { describe, expect, it } from \"vitest\";\n\nimport { parseGitHubRepoUrl } from \"~/features/diagram/github-url\";\n\ndes"
},
{
"path": "src/features/diagram/github-url.ts",
"chars": 436,
"preview": "export interface ParsedGitHubRepo {\n username: string;\n repo: string;\n}\n\nconst GITHUB_URL_PATTERN =\n /^https?:\\/\\/git"
},
{
"path": "src/features/diagram/sse.test.ts",
"chars": 1400,
"preview": "import { describe, expect, it } from \"vitest\";\n\nimport { parseSSEChunk, parseSSEStreamBuffer } from \"~/features/diagram/"
},
{
"path": "src/features/diagram/sse.ts",
"chars": 1027,
"preview": "import type { DiagramStreamMessage } from \"~/features/diagram/types\";\n\nexport function parseSSEChunk(chunk: string): Dia"
},
{
"path": "src/features/diagram/types.ts",
"chars": 1189,
"preview": "export type DiagramStreamStatus =\n | \"idle\"\n | \"started\"\n | \"explanation_sent\"\n | \"explanation\"\n | \"explanation_chu"
},
{
"path": "src/hooks/diagram/useDiagramExport.ts",
"chars": 548,
"preview": "import { useCallback } from \"react\";\n\nimport { exportMermaidSvgAsPng } from \"~/features/diagram/export\";\n\nexport functio"
},
{
"path": "src/hooks/diagram/useDiagramStream.test.ts",
"chars": 1248,
"preview": "import { act, renderHook } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { u"
},
{
"path": "src/hooks/diagram/useDiagramStream.ts",
"chars": 4538,
"preview": "import { useCallback, useState } from \"react\";\n\nimport { streamDiagramGeneration } from \"~/features/diagram/api\";\nimport"
},
{
"path": "src/hooks/useDiagram.ts",
"chars": 5157,
"preview": "import { useState, useEffect, useCallback } from \"react\";\n\nimport {\n cacheDiagramAndExplanation,\n getCachedDiagram,\n} "
},
{
"path": "src/hooks/useStarReminder.tsx",
"chars": 1033,
"preview": "\"use client\";\n\nimport { useEffect } from \"react\";\nimport { toast } from \"sonner\";\n\nexport function useStarReminder() {\n "
},
{
"path": "src/lib/exampleRepos.ts",
"chars": 641,
"preview": "export const exampleRepos = {\n FastAPI: \"/fastapi/fastapi\",\n Streamlit: \"/streamlit/streamlit\",\n Flask: \"/pallets/fla"
},
{
"path": "src/lib/utils.ts",
"chars": 166,
"preview": "import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: Cla"
},
{
"path": "src/server/db/index.ts",
"chars": 1103,
"preview": "import * as schema from \"./schema\";\nimport { drizzle as drizzleNeon } from \"drizzle-orm/neon-http\";\nimport { drizzle as "
},
{
"path": "src/server/db/schema.ts",
"chars": 1362,
"preview": "// Example model schema from the Drizzle docs\n// https://orm.drizzle.team/docs/sql-schema-declaration\n\nimport { sql } fr"
},
{
"path": "src/server/generate/format.ts",
"chars": 1384,
"preview": "type TaggedValues = Record<string, string | undefined>;\n\nexport function toTaggedMessage(values: TaggedValues): string {"
},
{
"path": "src/server/generate/github.ts",
"chars": 3535,
"preview": "interface GitHubRepoResponse {\n default_branch?: string;\n}\n\ninterface GitHubTreeItem {\n path: string;\n}\n\ninterface Git"
},
{
"path": "src/server/generate/mermaid.test.ts",
"chars": 557,
"preview": "import { describe, expect, it } from \"vitest\";\n\nimport { validateMermaidSyntax } from \"~/server/generate/mermaid\";\n\ndesc"
},
{
"path": "src/server/generate/mermaid.ts",
"chars": 3332,
"preview": "import { createRequire } from \"node:module\";\n\nimport DOMPurify from \"dompurify\";\nimport type { Mermaid as MermaidClient "
},
{
"path": "src/server/generate/model-config.ts",
"chars": 274,
"preview": "const DEFAULT_MODEL = \"gpt-5.4-mini\";\n\nfunction readEnvValue(name: string): string | undefined {\n const value = process"
},
{
"path": "src/server/generate/openai.ts",
"chars": 2335,
"preview": "import OpenAI from \"openai\";\n\nexport type ReasoningEffort = \"low\" | \"medium\" | \"high\";\n\nfunction resolveApiKey(overrideA"
},
{
"path": "src/server/generate/pricing.test.ts",
"chars": 810,
"preview": "import { describe, expect, it } from \"vitest\";\n\nimport { estimateTextTokenCostUsd, resolvePricingModel } from \"~/server/"
},
{
"path": "src/server/generate/pricing.ts",
"chars": 3194,
"preview": "export interface ModelPricing {\n inputPerMillionUsd: number;\n outputPerMillionUsd: number;\n}\n\nconst DEFAULT_PRICING_MO"
},
{
"path": "src/server/generate/prompts.ts",
"chars": 11016,
"preview": "export const SYSTEM_FIRST_PROMPT = `\nYou are tasked with explaining to a principal software engineer how to draw the bes"
},
{
"path": "src/server/generate/types.ts",
"chars": 414,
"preview": "import { z } from \"zod\";\n\nexport const generateRequestSchema = z.object({\n username: z.string().min(1),\n repo: z.strin"
},
{
"path": "src/server/github-stars.ts",
"chars": 1196,
"preview": "import \"server-only\";\n\ninterface GitHubRepoResponse {\n stargazers_count: number;\n}\n\nconst GITHUB_REPO_URL =\n \"https://"
},
{
"path": "src/styles/globals.css",
"chars": 5104,
"preview": "@import \"tailwindcss\";\n@config \"../../tailwind.config.ts\";\n@layer base {\n :root {\n --background: 269 100% 95%;\n -"
},
{
"path": "start-database.sh",
"chars": 2072,
"preview": "#!/usr/bin/env bash\n# Use this script to start a docker container for a local development database\n\n# TO RUN ON WINDOWS:"
},
{
"path": "tailwind.config.ts",
"chars": 2735,
"preview": "import { type Config } from \"tailwindcss\";\nimport defaultTheme from \"tailwindcss/defaultTheme\";\n\nexport default {\n dark"
},
{
"path": "tsconfig.json",
"chars": 1029,
"preview": "{\n \"compilerOptions\": {\n /* Base Options: */\n \"esModuleInterop\": true,\n \"skipLibCheck\": true,\n \"target\": \"e"
},
{
"path": "vitest.config.ts",
"chars": 358,
"preview": "import { defineConfig } from \"vitest/config\";\nimport { fileURLToPath } from \"node:url\";\n\nexport default defineConfig({\n "
},
{
"path": "vitest.setup.ts",
"chars": 43,
"preview": "import \"@testing-library/jest-dom/vitest\";\n"
}
]
About this extraction
This page contains the full source code of the ahmedkhaleel2004/gitdiagram GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 112 files (230.4 KB), approximately 60.2k tokens, and a symbol index with 195 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.