diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f754059 --- /dev/null +++ b/.gitignore @@ -0,0 +1,76 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Flask stuff: +instance/ +.webassets-cache + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Project specific +*.html +temp/ +tmp/ \ No newline at end of file diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..aaf27c3 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,183 @@ +# Deployment & Publishing Guide for rendergit + +## Quick Start + +```bash +# 1. Build package for PyPI +python -m build + +# 2. Upload to PyPI +python -m twine upload dist/* + +# 3. Deploy to Render +git push origin main +# Then connect repo on render.com +``` + +## 📦 Publishing to PyPI + +### First-time setup + +1. **Create PyPI account** + - Go to https://pypi.org/account/register/ + - Verify your email + +2. **Install build tools** + ```bash + pip install --upgrade pip build twine + ``` + +3. **Create API token** + - Go to https://pypi.org/manage/account/token/ + - Create a token with scope "Entire account" + - Save the token securely + +### Building and Publishing + +1. **Update version** in `pyproject.toml` (currently 0.2.0) + +2. **Build the package** + ```bash + python -m build + ``` + This creates `dist/` directory with wheel and source distribution + +3. **Upload to TestPyPI (optional, for testing)** + ```bash + python -m twine upload --repository testpypi dist/* + ``` + Test install: `pip install -i https://test.pypi.org/simple/ rendergit` + +4. **Upload to PyPI** + ```bash + python -m twine upload dist/* + ``` + Enter your PyPI username: `__token__` + Enter your password: `[paste your API token]` + +5. **Verify installation** + ```bash + pip install rendergit + rendergit --help + ``` + +## 🚀 Deploying to Render + +### Quick Deploy (Recommended) + +1. **Push to GitHub** + ```bash + git add . + git commit -m "Add web deployment configuration" + git push origin main + ``` + +2. **Deploy on Render** + - Go to https://render.com + - Sign up/Login with GitHub + - Click "New +" → "Web Service" + - Connect your GitHub repo + - Render will auto-detect the `render.yaml` configuration + - Click "Create Web Service" + +3. **Your app will be live at:** + ``` + https://rendergit.onrender.com + ``` + +### Manual Deploy Alternative + +If you prefer manual configuration: + +1. On Render dashboard: + - New → Web Service + - Connect GitHub repo + - Configure: + - **Name**: rendergit + - **Environment**: Python + - **Build Command**: `pip install -r requirements.txt` + - **Start Command**: `gunicorn app:app` + - **Plan**: Free + +## 🛠️ Local Development + +### Running the CLI locally +```bash +# Install in development mode +pip install -e . + +# Test the CLI +rendergit https://github.com/karpathy/nanoGPT +``` + +### Running the web app locally +```bash +# Install dependencies +pip install -r requirements.txt + +# Run Flask development server +python app.py + +# Or with gunicorn (production-like) +gunicorn app:app --bind 0.0.0.0:5000 +``` + +Visit http://localhost:5000 + +## 🔧 Configuration + +### Environment Variables (for Render) + +You can set these in Render dashboard → Environment: + +- `PORT`: Auto-set by Render +- `PYTHON_VERSION`: Set in render.yaml (3.11.0) + +### Updating the package + +1. Make changes to code +2. Update version in `pyproject.toml` +3. Build and publish to PyPI (see above) +4. Push to GitHub (auto-deploys to Render) + +## 📝 File Structure + +``` +rendergit/ +├── rendergit/ # Package directory +│ ├── __init__.py # Package initialization +│ └── cli.py # CLI implementation (renamed from repo_to_single_page.py) +├── app.py # Flask web application +├── pyproject.toml # Package configuration +├── requirements.txt # Web app dependencies +├── render.yaml # Render deployment config +├── README.md # Project documentation +├── DEPLOY.md # This file +└── .gitignore # Git ignore rules +``` + +## 🐛 Troubleshooting + +### PyPI Upload Issues +- **Authentication failed**: Check API token is correct +- **Version exists**: Increment version in pyproject.toml +- **Missing files**: Ensure `python -m build` ran successfully + +### Render Deployment Issues +- **Build failed**: Check requirements.txt has all dependencies +- **App crashes**: Check logs in Render dashboard +- **Slow cold starts**: Normal on free tier, upgrades available + +### Local Development Issues +- **Import errors**: Run `pip install -e .` from project root +- **Git not found**: Ensure git is installed and in PATH + +## 🔗 Links + +- **PyPI Package**: https://pypi.org/project/rendergit/ +- **Live Web App**: https://rendergit.onrender.com +- **GitHub Repo**: https://github.com/yourusername/rendergit + +## 📄 License + +Apache 2.0 - See LICENSE file \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..ec6228a --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include README.md +include LICENSE +include repo_to_single_page.py \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..0b993f8 --- /dev/null +++ b/app.py @@ -0,0 +1,551 @@ +#!/usr/bin/env python3 +""" +Flask web app for rendergit with caching and repo cards +""" + +import os +import sys +import json +import time +import hashlib +import tempfile +from datetime import datetime, timedelta +from flask import Flask, request, Response +from pathlib import Path + +# Ensure current directory is in path for imports +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# Import from original CLI +from repo_to_single_page import ( + git_clone, git_head_commit, collect_files, + build_html, MAX_DEFAULT_BYTES +) + +app = Flask(__name__) + +# Cache configuration +CACHE_DIR = Path('/tmp/rendergit_cache') +CACHE_DIR.mkdir(exist_ok=True) +CACHE_METADATA = CACHE_DIR / 'metadata.json' +CACHE_TTL_HOURS = 24 # Cache for 24 hours +MAX_CACHED_REPOS = 100 # Keep last 100 repos + +def get_cache_key(repo_url): + """Generate cache key from repo URL""" + return hashlib.md5(repo_url.encode()).hexdigest() + +def load_metadata(): + """Load cache metadata""" + if CACHE_METADATA.exists(): + try: + with open(CACHE_METADATA, 'r') as f: + return json.load(f) + except: + pass + return {'repos': {}} + +def save_metadata(metadata): + """Save cache metadata""" + with open(CACHE_METADATA, 'w') as f: + json.dump(metadata, f) + +def cleanup_old_cache(): + """Remove old cached items""" + metadata = load_metadata() + now = time.time() + cutoff_time = now - (CACHE_TTL_HOURS * 3600) + + # Remove expired entries + expired = [] + for key, info in metadata['repos'].items(): + if info['timestamp'] < cutoff_time: + expired.append(key) + cache_file = CACHE_DIR / f"{key}.html" + cache_file.unlink(missing_ok=True) + + for key in expired: + del metadata['repos'][key] + + # Keep only last N repos if exceeded + if len(metadata['repos']) > MAX_CACHED_REPOS: + sorted_repos = sorted(metadata['repos'].items(), + key=lambda x: x[1]['timestamp']) + for key, _ in sorted_repos[:-MAX_CACHED_REPOS]: + del metadata['repos'][key] + cache_file = CACHE_DIR / f"{key}.html" + cache_file.unlink(missing_ok=True) + + save_metadata(metadata) + +@app.route('/loading/') +def loading_page(repo_path): + """Show loading page while repo is being processed""" + repo_name = repo_path.split('/')[-1] + return f''' + + + + + + Loading {repo_name} - rendergit + + + +
+

🚀 rendergit

+
+

Processing {repo_name}

+
+
📥 Cloning repository...
+
📂 Collecting files...
+
🎨 Rendering HTML...
+
💾 Caching result...
+
+

This may take a few moments for large repositories

+
+ + + + ''' + +@app.route('/') +def index(): + """Show homepage with repo cards""" + metadata = load_metadata() + repos = metadata.get('repos', {}) + + # Sort by most recent + sorted_repos = sorted(repos.items(), + key=lambda x: x[1]['timestamp'], + reverse=True) + + cards_html = '' + for key, info in sorted_repos[:20]: # Show last 20 + cards_html += f''' + +
+

{info['name']}

+

{info['url']}

+
+
+ ''' + + if not cards_html: + cards_html = '''
+

No repositories rendered yet.

+

Try one of the examples above or enter your own GitHub URL!

+
''' + + return f''' + + + + + + rendergit + + + +
+

🚀 rendergit

+

Flatten GitHub repositories into single HTML pages

+ +
+ + +
+ +

Recently Rendered

+
+ {cards_html} +
+ +
+
+ Web +
+ {request.host_url}github.com/karpathy/nanoGPT + +
+
+ +
+ CLI +
+ pip install rendergit && rendergit https://github.com/karpathy/nanoGPT + +
+
+
+
+ + + + + ''' + +@app.route('/') +def render_repo(repo_path): + """Render a repository with caching""" + try: + # Parse URL + if repo_path.startswith('https://github.com/') or repo_path.startswith('http://github.com/'): + repo_url = repo_path.replace('http://', 'https://') + elif repo_path.startswith('github.com/'): + repo_url = f'https://{repo_path}' + else: + return f'Invalid path. Use: {request.host_url}github.com/user/repo', 400 + + # Check cache + cache_key = get_cache_key(repo_url) + cache_file = CACHE_DIR / f"{cache_key}.html" + metadata = load_metadata() + + # Check if cached and not expired + if cache_file.exists() and cache_key in metadata['repos']: + cache_info = metadata['repos'][cache_key] + if time.time() - cache_info['timestamp'] < (CACHE_TTL_HOURS * 3600): + # Update access time + cache_info['last_accessed'] = time.time() + save_metadata(metadata) + + # Return cached content + with open(cache_file, 'r', encoding='utf-8') as f: + return f.read() + + # If not processing flag, show loading page first + if 'process' not in request.args: + return loading_page(repo_path) + + # Generate fresh content + with tempfile.TemporaryDirectory() as tmpdir: + repo_dir = os.path.join(tmpdir, 'repo') + git_clone(repo_url, repo_dir) + + commit = git_head_commit(repo_dir) + repo_path_obj = Path(repo_dir) + files = collect_files(repo_path_obj, MAX_DEFAULT_BYTES) + + html_content = build_html(repo_url, Path(repo_dir), commit, files) + + # Cache the result + with open(cache_file, 'w', encoding='utf-8') as f: + f.write(html_content) + + # Update metadata + repo_name = repo_url.rstrip('/').split('/')[-1] + metadata['repos'][cache_key] = { + 'url': repo_url, + 'path': repo_path, + 'name': repo_name, + 'timestamp': time.time(), + 'last_accessed': time.time(), + 'commit': commit[:8] if commit else 'unknown' + } + save_metadata(metadata) + + # Cleanup old cache periodically + if len(metadata['repos']) % 10 == 0: + cleanup_old_cache() + + return html_content + + except Exception as e: + return f''' + + +

Error

+

{str(e)}

+

Usage: {request.host_url}github.com/user/repo

+ ← Back to home + + + ''', 500 + +if __name__ == '__main__': + port = int(os.environ.get('PORT', 5000)) + app.run(host='0.0.0.0', port=port) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 943a299..fc6bdfa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,14 +2,18 @@ requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" +[tool.setuptools] +py-modules = ["repo_to_single_page"] + [project] name = "rendergit" -version = "0.1.0" +version = "0.2.0" description = "Flatten a GitHub repo into a single static HTML page for fast skimming and Ctrl+F" readme = "README.md" license = {text = "Apache-2.0"} authors = [ {name = "Andrej Karpathy"}, + {name = "Eden Chan", email = "edenchan717@gmail.com"}, ] dependencies = [ "pygments", @@ -32,5 +36,5 @@ classifiers = [ rendergit = "repo_to_single_page:main" [project.urls] -Homepage = "https://github.com/karpathy/rendergit" -Repository = "https://github.com/karpathy/rendergit" \ No newline at end of file +Homepage = "https://github.com/eden-chan/rendergit" +Repository = "https://github.com/eden-chan/rendergit" \ No newline at end of file diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..9547b10 --- /dev/null +++ b/render.yaml @@ -0,0 +1,11 @@ +services: + - type: web + name: rendergit + runtime: python + plan: free + buildCommand: "pip install -r requirements.txt" + startCommand: "gunicorn app:app" + envVars: + - key: PYTHON_VERSION + value: 3.11.0 + autoDeploy: true \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c8eec3a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +flask==3.0.0 +pygments==2.17.2 +markdown==3.5.1 +gunicorn==21.2.0 \ No newline at end of file