From a1428df4402a997e81288952670776a82a07d458 Mon Sep 17 00:00:00 2001 From: Brent Perteet Date: Sun, 1 Mar 2026 11:35:14 -0600 Subject: [PATCH] initial commit --- .dockerignore | 24 ++ DEPLOYMENT.md | 227 +++++++++++++ Dockerfile | 40 +++ README.md | 76 +++++ app.py | 339 +++++++++++++++++++ docker-compose.yml | 22 ++ markdown-app.service | 17 + nginx-markdown.conf | 52 +++ requirements.txt | 6 + templates/index.html | 754 +++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 1557 insertions(+) create mode 100644 .dockerignore create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.py create mode 100644 docker-compose.yml create mode 100644 markdown-app.service create mode 100644 nginx-markdown.conf create mode 100644 requirements.txt create mode 100644 templates/index.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b0a5573 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info +dist +build +.git +.gitignore +.env +.venv +venv/ +ENV/ +.DS_Store +*.md +!requirements.txt +.dockerignore +Dockerfile +docker-compose.yml +markdown-app.service +nginx-markdown.conf diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..479d736 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,227 @@ +# Deployment Guide + +This guide covers deploying the Markdown Renderer application to a server with nginx using Docker. + +## Prerequisites + +- Docker and Docker Compose installed on your server +- nginx installed and running +- sudo/root access to the server + +## Deployment Steps + +### 1. Transfer Files to Server + +Copy the application files to your server: + +```bash +# On your local machine +scp -r /Users/brent/markdown user@yourserver:/tmp/markdown-app + +# On the server +sudo mkdir -p /opt/markdown-app +sudo mv /tmp/markdown-app/* /opt/markdown-app/ +sudo chown -R $USER:$USER /opt/markdown-app +``` + +### 2. Build and Start the Docker Container + +```bash +cd /opt/markdown-app +docker-compose up -d +``` + +Verify the container is running: + +```bash +docker-compose ps +docker-compose logs +``` + +### 3. Configure nginx + +Copy the nginx configuration and enable it: + +```bash +# Copy the configuration file +sudo cp /opt/markdown-app/nginx-markdown.conf /etc/nginx/sites-available/markdown + +# Edit the configuration to set your domain name +sudo nano /etc/nginx/sites-available/markdown +# Change 'markdown.yourdomain.com' to your actual domain or server IP + +# Enable the site +sudo ln -s /etc/nginx/sites-available/markdown /etc/nginx/sites-enabled/ + +# Test nginx configuration +sudo nginx -t + +# Reload nginx +sudo systemctl reload nginx +``` + +### 4. Enable Auto-Start on Server Boot + +Install the systemd service: + +```bash +# Copy the service file +sudo cp /opt/markdown-app/markdown-app.service /etc/systemd/system/ + +# Reload systemd +sudo systemctl daemon-reload + +# Enable the service to start on boot +sudo systemctl enable markdown-app.service + +# Start the service +sudo systemctl start markdown-app.service + +# Check status +sudo systemctl status markdown-app.service +``` + +### 5. Optional: Configure SSL with Let's Encrypt + +If you want HTTPS (recommended for production): + +```bash +# Install certbot +sudo apt-get update +sudo apt-get install certbot python3-certbot-nginx + +# Obtain certificate (replace with your domain) +sudo certbot --nginx -d markdown.yourdomain.com + +# Certbot will automatically configure nginx for HTTPS +``` + +## Management Commands + +### View Logs + +```bash +# Docker container logs +docker-compose logs -f + +# nginx logs +sudo tail -f /var/log/nginx/markdown-access.log +sudo tail -f /var/log/nginx/markdown-error.log + +# Systemd service logs +sudo journalctl -u markdown-app.service -f +``` + +### Restart Services + +```bash +# Restart the Docker container +sudo systemctl restart markdown-app.service +# OR +cd /opt/markdown-app && docker-compose restart + +# Restart nginx +sudo systemctl restart nginx +``` + +### Update the Application + +```bash +cd /opt/markdown-app + +# Pull new changes (if using git) +git pull + +# Rebuild and restart +docker-compose down +docker-compose build --no-cache +docker-compose up -d + +# OR if using systemd service +sudo systemctl restart markdown-app.service +``` + +### Stop Services + +```bash +# Stop the Docker container +sudo systemctl stop markdown-app.service +# OR +cd /opt/markdown-app && docker-compose down +``` + +## Firewall Configuration + +If you have a firewall enabled, allow nginx: + +```bash +# For ufw +sudo ufw allow 'Nginx Full' + +# For firewalld +sudo firewall-cmd --permanent --add-service=http +sudo firewall-cmd --permanent --add-service=https +sudo firewall-cmd --reload +``` + +## Troubleshooting + +### Container won't start + +```bash +# Check Docker logs +docker-compose logs + +# Check if port 8080 is already in use +sudo lsof -i :8080 +``` + +### nginx returns 502 Bad Gateway + +```bash +# Verify container is running +docker-compose ps + +# Check if the app is responding +curl http://127.0.0.1:8080 +``` + +### Service doesn't start on boot + +```bash +# Check service status +sudo systemctl status markdown-app.service + +# Check if service is enabled +sudo systemctl is-enabled markdown-app.service + +# View service logs +sudo journalctl -u markdown-app.service --no-pager +``` + +## Accessing the Application + +- If configured with a domain: `http://markdown.yourdomain.com` +- If using server IP: `http://your.server.ip.address` +- With HTTPS: `https://markdown.yourdomain.com` + +## Security Recommendations + +1. Configure a firewall to only allow ports 80 (HTTP) and 443 (HTTPS) +2. Use HTTPS with Let's Encrypt certificates +3. Keep Docker and nginx updated +4. Consider adding rate limiting in nginx +5. Regularly update the application dependencies +6. Monitor logs for suspicious activity + +## Performance Tuning + +The docker-compose.yml is configured with: +- 4 gunicorn workers (adjust based on CPU cores) +- 120-second timeout for PDF generation +- Log rotation (max 3 files of 10MB each) + +To adjust workers, edit the Dockerfile CMD line: +```dockerfile +CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "4", "--timeout", "120", "app:app"] +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..422b0f7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +FROM python:3.11-slim + +# Install system dependencies required for WeasyPrint +RUN apt-get update && apt-get install -y \ + libpango-1.0-0 \ + libpangoft2-1.0-0 \ + libgdk-pixbuf2.0-0 \ + libffi-dev \ + libcairo2 \ + libgirepository-1.0-1 \ + gir1.2-pango-1.0 \ + shared-mime-info \ + && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +# Copy requirements first for better caching +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt gunicorn + +# Copy application files +COPY app.py . +COPY templates/ templates/ + +# Create non-root user for security +RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app +USER appuser + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/').read()" || exit 1 + +# Run with gunicorn for production +CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "4", "--timeout", "120", "app:app"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e80211 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# Markdown Editor with LaTeX Support + +A real-time markdown editor with LaTeX equation rendering in a split-pane web interface. + +## Features + +- **Split-pane interface**: Left pane for editing, right pane for live preview +- **Real-time rendering**: See your changes as you type (with 300ms debounce) +- **LaTeX support**: Render mathematical equations using MathJax (SVG mode) + - Inline equations: `$E = mc^2$` or `\(E = mc^2\)` + - Block equations: `$$...$$` or `\[...\]` +- **PDF Export**: Export your rendered markdown with properly formatted equations to PDF +- **Markdown features**: + - Headers, bold, italic, lists + - Code blocks with syntax highlighting + - Tables, blockquotes, links, images + - And more... + +## Installation + +1. Install Python dependencies: +```bash +pip install -r requirements.txt +``` + +## Usage + +1. Start the Flask server: +```bash +python app.py +``` + +2. Open your browser and navigate to: +``` +http://localhost:8080 +``` + +3. Start typing or pasting markdown in the left pane. The right pane will update automatically with the rendered output. + +## LaTeX Examples + +Inline equation: +``` +The famous equation $E = mc^2$ shows the relationship between energy and mass. +``` + +Block equation: +``` +$$ +\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi} +$$ +``` + +More complex equation: +``` +$$ +\frac{d}{dx}\left( \int_{0}^{x} f(u)\,du\right)=f(x) +$$ +``` + +## PDF Export + +Click the green "Export to PDF" button in the upper right corner of the preview pane to generate a PDF of your rendered markdown. The PDF will include: +- All markdown formatting (headers, lists, tables, code blocks, etc.) +- LaTeX equations rendered as SVG (both inline and display equations) +- Syntax-highlighted code blocks +- Professional typography and layout + +## Technology Stack + +- **Backend**: Flask (Python) +- **Frontend**: Vanilla JavaScript +- **Markdown**: Python-Markdown library +- **LaTeX**: MathJax 3 (SVG output mode) +- **Syntax Highlighting**: Highlight.js +- **PDF Generation**: WeasyPrint diff --git a/app.py b/app.py new file mode 100644 index 0000000..980438d --- /dev/null +++ b/app.py @@ -0,0 +1,339 @@ +from flask import Flask, render_template, request, jsonify, send_file +import markdown +from markdown.extensions import fenced_code, tables, codehilite +import re +from weasyprint import HTML, CSS +from io import BytesIO + +app = Flask(__name__) + +# Configure markdown with extensions +md = markdown.Markdown(extensions=[ + 'fenced_code', + 'tables', + 'codehilite', + 'extra', + 'nl2br' +]) + +def protect_math(text): + """Protect LaTeX math expressions from markdown processing""" + # Store math expressions + math_blocks = [] + + # Protect display math ($$...$$) + def save_display_math(match): + math_blocks.append(('display', match.group(1))) + return f'MATHBLOCK{len(math_blocks)-1}MATHBLOCK' + + # Protect inline math ($...$) + def save_inline_math(match): + math_blocks.append(('inline', match.group(1))) + return f'MATHBLOCK{len(math_blocks)-1}MATHBLOCK' + + # Replace display math first (greedy, multiline) + text = re.sub(r'\$\$(.*?)\$\$', save_display_math, text, flags=re.DOTALL) + # Then inline math (don't allow $ inside, don't cross newlines) + text = re.sub(r'\$([^\$\n]+)\$', save_inline_math, text) + + return text, math_blocks + +def restore_math(text, math_blocks): + """Restore LaTeX math expressions after markdown processing""" + for i, (math_type, content) in enumerate(math_blocks): + placeholder = f'MATHBLOCK{i}MATHBLOCK' + if math_type == 'display': + replacement = f'$$\n{content}\n$$' + else: + # Use \( \) delimiters for inline math as they're more robust + replacement = f'\\({content}\\)' + + # Replace both the placeholder and any HTML-escaped version + text = text.replace(placeholder, replacement) + + return text + +@app.route('/') +def index(): + return render_template('index.html') + +@app.route('/render', methods=['POST']) +def render_markdown(): + """Convert markdown to HTML""" + data = request.get_json() + markdown_text = data.get('markdown', '') + + # Protect math expressions before markdown processing + protected_text, math_blocks = protect_math(markdown_text) + + # Reset the markdown instance to clear state + md.reset() + + # Convert markdown to HTML + html = md.convert(protected_text) + + # Restore math expressions after markdown processing + html = restore_math(html, math_blocks) + + return jsonify({'html': html}) + +def convert_latex_to_mathml(html_content): + """Convert LaTeX expressions to MathML for PDF rendering""" + from latex2mathml.converter import convert as latex_to_mathml + + # Convert display math $$...$$ to MathML + def replace_display(match): + latex = match.group(1).strip() + try: + mathml = latex_to_mathml(latex) + return f'
{mathml}
' + except Exception as e: + print(f"Error converting display LaTeX: {latex[:50]}... Error: {e}") + return f'
{latex}
' + + # Convert inline math \(...\) to MathML + def replace_inline(match): + latex = match.group(1).strip() + try: + mathml = latex_to_mathml(latex) + return mathml + except Exception as e: + print(f"Error converting inline LaTeX: {latex[:50]}... Error: {e}") + return f'{latex}' + + # Process display math + html_content = re.sub(r'\$\$(.*?)\$\$', replace_display, html_content, flags=re.DOTALL) + # Process inline math + html_content = re.sub(r'\\[(](.*?)\\[)]', replace_inline, html_content, flags=re.DOTALL) + + return html_content + +@app.route('/export-pdf', methods=['POST']) +def export_pdf(): + """Generate PDF from rendered HTML with embedded equation images""" + data = request.get_json() + html_content = data.get('html', '') + + # Create a complete HTML document with styles + full_html = f''' + + + + + + + + {html_content} + + + ''' + + # Generate PDF + pdf_file = BytesIO() + HTML(string=full_html).write_pdf(pdf_file) + pdf_file.seek(0) + + return send_file( + pdf_file, + mimetype='application/pdf', + as_attachment=True, + download_name='markdown-export.pdf' + ) + +@app.route('/export-latex', methods=['POST']) +def export_latex(): + """Convert markdown to LaTeX document""" + data = request.get_json() + markdown_text = data.get('markdown', '') + + # Convert markdown elements to LaTeX + latex_content = markdown_to_latex(markdown_text) + + # Create response with LaTeX file + return jsonify({'latex': latex_content}) + +def markdown_to_latex(markdown_text): + """Convert markdown syntax to LaTeX""" + + # Start with document structure + latex = r'''\documentclass{article} +\usepackage{amsmath} +\usepackage{amssymb} +\usepackage{graphicx} +\usepackage{hyperref} +\usepackage{listings} +\usepackage{xcolor} + +\lstset{ + basicstyle=\ttfamily, + breaklines=true, + frame=single, + backgroundcolor=\color{gray!10} +} + +\begin{document} + +''' + + lines = markdown_text.split('\n') + i = 0 + in_code_block = False + code_lang = '' + + while i < len(lines): + line = lines[i] + + # Code blocks + if line.startswith('```'): + if not in_code_block: + in_code_block = True + code_lang = line[3:].strip() + latex += r'\begin{lstlisting}' + if code_lang: + latex += f'[language={code_lang}]' + latex += '\n' + else: + in_code_block = False + latex += r'\end{lstlisting}' + '\n' + i += 1 + continue + + if in_code_block: + latex += line + '\n' + i += 1 + continue + + # Headers + if line.startswith('# '): + latex += r'\section{' + line[2:] + '}\n' + elif line.startswith('## '): + latex += r'\subsection{' + line[3:] + '}\n' + elif line.startswith('### '): + latex += r'\subsubsection{' + line[4:] + '}\n' + # Horizontal rule + elif line.strip() == '---' or line.strip() == '***': + latex += r'\hrulefill' + '\n\n' + # Empty lines + elif line.strip() == '': + latex += '\n' + # Regular paragraphs + else: + # Convert inline markdown + processed_line = line + # Bold + processed_line = re.sub(r'\*\*(.+?)\*\*', r'\\textbf{\1}', processed_line) + # Italic + processed_line = re.sub(r'\*(.+?)\*', r'\\textit{\1}', processed_line) + # Inline code + processed_line = re.sub(r'`(.+?)`', r'\\texttt{\1}', processed_line) + + latex += processed_line + '\n' + + i += 1 + + latex += r''' +\end{document} +''' + + return latex + +if __name__ == '__main__': + app.run(debug=True, port=8080) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3268b26 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.8' + +services: + markdown-app: + build: . + container_name: markdown-renderer + restart: unless-stopped + ports: + - "127.0.0.1:8080:8080" + environment: + - FLASK_ENV=production + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/').read()"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 5s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" diff --git a/markdown-app.service b/markdown-app.service new file mode 100644 index 0000000..678efdb --- /dev/null +++ b/markdown-app.service @@ -0,0 +1,17 @@ +[Unit] +Description=Markdown Renderer Docker Container +Requires=docker.service +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/opt/markdown-app +ExecStart=/usr/bin/docker-compose up -d +ExecStop=/usr/bin/docker-compose down +ExecReload=/usr/bin/docker-compose restart +TimeoutStartSec=0 + +[Install] +WantedBy=multi-user.target diff --git a/nginx-markdown.conf b/nginx-markdown.conf new file mode 100644 index 0000000..35618a0 --- /dev/null +++ b/nginx-markdown.conf @@ -0,0 +1,52 @@ +server { + listen 80; + server_name markdown.yourdomain.com; # Change this to your domain + + # Optional: Redirect HTTP to HTTPS + # return 301 https://$server_name$request_uri; + + client_max_body_size 10M; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Timeouts for PDF generation + proxy_connect_timeout 120s; + proxy_send_timeout 120s; + proxy_read_timeout 120s; + } + + # Optional: Access and error logs + access_log /var/log/nginx/markdown-access.log; + error_log /var/log/nginx/markdown-error.log; +} + +# Optional: HTTPS configuration (uncomment and configure after obtaining SSL certificate) +# server { +# listen 443 ssl http2; +# server_name markdown.yourdomain.com; +# +# ssl_certificate /etc/letsencrypt/live/markdown.yourdomain.com/fullchain.pem; +# ssl_certificate_key /etc/letsencrypt/live/markdown.yourdomain.com/privkey.pem; +# +# client_max_body_size 10M; +# +# location / { +# proxy_pass http://127.0.0.1:8080; +# proxy_set_header Host $host; +# proxy_set_header X-Real-IP $remote_addr; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header X-Forwarded-Proto $scheme; +# +# proxy_connect_timeout 120s; +# proxy_send_timeout 120s; +# proxy_read_timeout 120s; +# } +# +# access_log /var/log/nginx/markdown-access.log; +# error_log /var/log/nginx/markdown-error.log; +# } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6e2b9d9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +Flask==3.0.0 +markdown==3.5.1 +Pygments==2.17.2 +weasyprint==62.3 +pydyf==0.11.0 +latex2mathml==3.77.0 diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..26ab84a --- /dev/null +++ b/templates/index.html @@ -0,0 +1,754 @@ + + + + + + Markdown Editor with LaTeX + + + + + + + + + + + + + +
+
+ +
+
+
+ + + +
+
+
+
+ + + +