markdown-viewer-app 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ """
2
+ Markdown Viewer - Beautiful markdown renderer with GitHub emoji, Mermaid diagrams, and KaTeX math.
3
+ """
4
+
5
+ __version__ = "1.0.0"
6
+ __author__ = "Ofelia B Webb"
7
+ __email__ = "ofelia.b.webb@gmail.com"
8
+
9
+ # Lazy imports to avoid loading Flask dependencies for CLI usage
10
+ __all__ = ["create_app", "start_server", "__version__"]
11
+
12
+
13
+ def __getattr__(name):
14
+ """Lazy import for Flask app components."""
15
+ if name == "create_app":
16
+ from .app import create_app
17
+ return create_app
18
+ elif name == "start_server":
19
+ from .server import start_server
20
+ return start_server
21
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,148 @@
1
+ """
2
+ Main entry point for the markdown-viewer application.
3
+ """
4
+
5
+ import sys
6
+ import os
7
+ import subprocess
8
+ import webbrowser
9
+ import time
10
+ from pathlib import Path
11
+ from .server import start_server
12
+
13
+
14
+ def check_electron():
15
+ """Check if Electron is available."""
16
+ electron_path = Path(__file__).parent / "electron"
17
+ package_json = electron_path / "package.json"
18
+ return package_json.exists()
19
+
20
+
21
+ def start_electron():
22
+ """Start the Electron application."""
23
+ electron_path = Path(__file__).parent / "electron"
24
+
25
+ # Check if node_modules exists
26
+ node_modules = electron_path / "node_modules"
27
+ if not node_modules.exists():
28
+ print("Installing Electron dependencies...")
29
+ try:
30
+ subprocess.run(["npm", "install"], cwd=electron_path, check=True)
31
+ except FileNotFoundError:
32
+ raise RuntimeError(
33
+ "npm not found. Please install Node.js from https://nodejs.org/ and ensure it is on your PATH."
34
+ )
35
+ except subprocess.CalledProcessError as e:
36
+ raise RuntimeError(f"npm install failed with exit code {e.returncode}.") from e
37
+
38
+ # Start Electron
39
+ print("Starting Markdown Viewer...")
40
+ try:
41
+ subprocess.Popen(["npm", "start"], cwd=electron_path)
42
+ except FileNotFoundError:
43
+ raise RuntimeError(
44
+ "npm not found. Please install Node.js from https://nodejs.org/ and ensure it is on your PATH."
45
+ )
46
+
47
+
48
+ def main():
49
+ """Main entry point."""
50
+ import argparse
51
+
52
+ parser = argparse.ArgumentParser(
53
+ description="Markdown Viewer - Advanced markdown viewer with export and translation"
54
+ )
55
+ parser.add_argument(
56
+ "file",
57
+ nargs="?",
58
+ help="Markdown file to open",
59
+ )
60
+ parser.add_argument(
61
+ "--port",
62
+ type=int,
63
+ default=5000,
64
+ help="Port for the backend server (default: 5000)",
65
+ )
66
+ parser.add_argument(
67
+ "--no-gui",
68
+ action="store_true",
69
+ help="Start server only without GUI",
70
+ )
71
+ parser.add_argument(
72
+ "--browser",
73
+ action="store_true",
74
+ help="Open in web browser instead of Electron",
75
+ )
76
+
77
+ args = parser.parse_args()
78
+
79
+ # Start the Flask backend server
80
+ print(f"Starting backend server on port {args.port}...")
81
+ server_process = start_server(port=args.port, debug=False)
82
+
83
+ # Wait for server to be ready with a health-check poll instead of fixed sleep
84
+ backend_url = f"http://localhost:{args.port}/api/health"
85
+ import urllib.request
86
+ for _ in range(20):
87
+ try:
88
+ urllib.request.urlopen(backend_url, timeout=1)
89
+ break
90
+ except Exception:
91
+ time.sleep(0.25)
92
+
93
+ if args.no_gui:
94
+ print(f"Server running at http://localhost:{args.port}")
95
+ print("Press Ctrl+C to stop")
96
+ try:
97
+ server_process.wait()
98
+ except KeyboardInterrupt:
99
+ print("\nShutting down...")
100
+ server_process.terminate()
101
+ elif args.browser:
102
+ url = f"http://localhost:{args.port}"
103
+ if args.file:
104
+ url += f"?file={args.file}"
105
+ print(f"Opening {url} in browser...")
106
+ webbrowser.open(url)
107
+ try:
108
+ server_process.join()
109
+ except KeyboardInterrupt:
110
+ print("\nShutting down...")
111
+ server_process.terminate()
112
+ else:
113
+ # Start Electron GUI
114
+ if check_electron():
115
+ try:
116
+ # Set environment variable for backend port
117
+ os.environ["BACKEND_PORT"] = str(args.port)
118
+ if args.file:
119
+ os.environ["MARKDOWN_FILE"] = args.file
120
+ start_electron()
121
+
122
+ # Keep server running
123
+ try:
124
+ server_process.join()
125
+ except KeyboardInterrupt:
126
+ print("\nShutting down...")
127
+ server_process.terminate()
128
+ except Exception as e:
129
+ print(f"Error starting Electron: {e}")
130
+ print("Falling back to browser mode...")
131
+ webbrowser.open(f"http://localhost:{args.port}")
132
+ try:
133
+ server_process.join()
134
+ except KeyboardInterrupt:
135
+ print("\nShutting down...")
136
+ server_process.terminate()
137
+ else:
138
+ print("Electron not found. Opening in browser...")
139
+ webbrowser.open(f"http://localhost:{args.port}")
140
+ try:
141
+ server_process.join()
142
+ except KeyboardInterrupt:
143
+ print("\nShutting down...")
144
+ server_process.terminate()
145
+
146
+
147
+ if __name__ == "__main__":
148
+ main()
markdown_viewer/app.py ADDED
@@ -0,0 +1,202 @@
1
+ """
2
+ Flask application factory and configuration.
3
+ """
4
+
5
+ from flask import Flask, g, jsonify, request, send_from_directory, Blueprint
6
+ from flask_cors import CORS
7
+ from flask_wtf.csrf import CSRFProtect
8
+ import os
9
+ import logging
10
+ from logging.handlers import RotatingFileHandler
11
+ from typing import Optional, Dict, Any
12
+ import uuid
13
+
14
+
15
+ # Configuration classes
16
+ class Config:
17
+ """Base configuration."""
18
+ SECRET_KEY = os.environ.get('SECRET_KEY')
19
+ MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB max file size
20
+ UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), "uploads")
21
+ TEMP_FOLDER = os.path.join(os.path.dirname(__file__), "temp")
22
+ ALLOWED_DOCUMENTS_DIR = os.environ.get('ALLOWED_DOCUMENTS_DIR', os.path.expanduser('~'))
23
+
24
+ # Security settings
25
+ SESSION_COOKIE_SECURE = True
26
+ SESSION_COOKIE_HTTPONLY = True
27
+ SESSION_COOKIE_SAMESITE = 'Strict'
28
+ WTF_CSRF_TIME_LIMIT = None # CSRF token doesn't expire
29
+
30
+
31
+ class DevelopmentConfig(Config):
32
+ """Development configuration."""
33
+ DEBUG = True
34
+ SESSION_COOKIE_SECURE = False # Allow HTTP in development
35
+
36
+
37
+ class ProductionConfig(Config):
38
+ """Production configuration."""
39
+ DEBUG = False
40
+
41
+
42
+ config_by_name = {
43
+ 'development': DevelopmentConfig,
44
+ 'production': ProductionConfig,
45
+ 'default': ProductionConfig
46
+ }
47
+
48
+
49
+ def configure_logging(app: Flask) -> None:
50
+ """Configure application logging."""
51
+ if not app.debug:
52
+ if not os.path.exists('logs'):
53
+ os.mkdir('logs')
54
+ file_handler = RotatingFileHandler(
55
+ 'logs/markdown_viewer.log',
56
+ maxBytes=10485760,
57
+ backupCount=10
58
+ )
59
+ file_handler.setFormatter(logging.Formatter(
60
+ '%(asctime)s %(levelname)s [%(name)s] [%(pathname)s:%(lineno)d] %(message)s'
61
+ ))
62
+ file_handler.setLevel(logging.INFO)
63
+ app.logger.addHandler(file_handler)
64
+ app.logger.setLevel(logging.INFO)
65
+ # Also capture logs from all markdown_viewer sub-modules
66
+ pkg_logger = logging.getLogger('markdown_viewer')
67
+ pkg_logger.addHandler(file_handler)
68
+ pkg_logger.setLevel(logging.INFO)
69
+ app.logger.info('Markdown Viewer startup')
70
+
71
+
72
+ def create_app(config: Optional[Dict[str, Any]] = None) -> Flask:
73
+ """Create and configure the Flask application."""
74
+ app = Flask(__name__)
75
+
76
+ # In production, SECRET_KEY must be explicitly set.
77
+ # In development, auto-generate an ephemeral key so the app starts without manual setup.
78
+ env = os.getenv('FLASK_ENV', 'production')
79
+ if not os.environ.get('SECRET_KEY') and (config is None or 'SECRET_KEY' not in config):
80
+ if env == 'production':
81
+ raise RuntimeError(
82
+ "SECRET_KEY environment variable must be set. "
83
+ "Generate one using: python -c 'import secrets; print(secrets.token_hex())'"
84
+ )
85
+ else:
86
+ import secrets as _secrets
87
+ os.environ['SECRET_KEY'] = _secrets.token_hex()
88
+
89
+ # Load configuration
90
+ try:
91
+ app.config.from_object(config_by_name.get(env, config_by_name['default']))
92
+ except ValueError as e:
93
+ # Re-raise configuration errors with clear message
94
+ raise RuntimeError(f"Configuration error: {e}") from e
95
+
96
+ # Config.SECRET_KEY is a class-level attribute evaluated at import time,
97
+ # so if we auto-generated the key above we must push it into app.config now.
98
+ if os.environ.get('SECRET_KEY'):
99
+ app.config['SECRET_KEY'] = os.environ['SECRET_KEY']
100
+
101
+ # Apply custom configuration
102
+ if config:
103
+ app.config.update(config)
104
+
105
+ # Configure logging
106
+ configure_logging(app)
107
+
108
+ # Enable CORS for Electron and local testing
109
+ backend_port = os.environ.get('BACKEND_PORT', '5000')
110
+
111
+ # In development, allow file:// and localhost origins
112
+ if env == 'development' or app.debug:
113
+ cors_origins = "*" # Allow all origins in development
114
+ else:
115
+ # In production, restrict to specific origins
116
+ cors_origins = [
117
+ f"http://localhost:{backend_port}",
118
+ f"http://127.0.0.1:{backend_port}",
119
+ "app://."
120
+ ]
121
+
122
+ CORS(app, resources={
123
+ r"/api/*": {
124
+ "origins": cors_origins,
125
+ "methods": ["GET", "POST", "OPTIONS"],
126
+ "allow_headers": ["Content-Type", "X-CSRF-Token", "X-CSRFToken"],
127
+ "expose_headers": ["Content-Type"],
128
+ "supports_credentials": True,
129
+ "max_age": 3600
130
+ }
131
+ })
132
+
133
+ # Enable CSRF protection
134
+ csrf = CSRFProtect()
135
+ csrf.init_app(app)
136
+
137
+ # Ensure required temp directory exists
138
+ os.makedirs(app.config["TEMP_FOLDER"], exist_ok=True)
139
+
140
+ # Add request ID for tracing
141
+ @app.before_request
142
+ def add_request_id():
143
+ g.request_id = str(uuid.uuid4())
144
+
145
+ @app.after_request
146
+ def log_request(response):
147
+ request_id = getattr(g, 'request_id', 'unknown')
148
+ app.logger.info(
149
+ f"[{request_id}] {request.method} {request.path} {response.status_code}"
150
+ )
151
+ return response
152
+
153
+ # Register blueprints
154
+ from .routes import api_bp
155
+ app.register_blueprint(api_bp, url_prefix="/api")
156
+
157
+ # Serve the Electron renderer UI for browser mode
158
+ renderer_dir = os.path.join(os.path.dirname(__file__), 'electron', 'renderer')
159
+ ui_bp = Blueprint('ui', __name__)
160
+
161
+ @ui_bp.route('/')
162
+ def index():
163
+ return send_from_directory(renderer_dir, 'index.html')
164
+
165
+ @ui_bp.route('/styles/<path:filename>')
166
+ def renderer_styles(filename):
167
+ return send_from_directory(os.path.join(renderer_dir, 'styles'), filename)
168
+
169
+ @ui_bp.route('/scripts/<path:filename>')
170
+ def renderer_scripts(filename):
171
+ return send_from_directory(os.path.join(renderer_dir, 'scripts'), filename)
172
+
173
+ app.register_blueprint(ui_bp)
174
+
175
+ # Register error handlers
176
+ from werkzeug.exceptions import HTTPException
177
+
178
+ @app.errorhandler(HTTPException)
179
+ def handle_http_exception(e):
180
+ """Handle HTTP exceptions with consistent format."""
181
+ return jsonify({
182
+ "success": False,
183
+ "error": {
184
+ "message": e.description,
185
+ "type": e.name,
186
+ "code": e.code
187
+ }
188
+ }), e.code
189
+
190
+ @app.errorhandler(Exception)
191
+ def handle_exception(e):
192
+ """Handle all other exceptions."""
193
+ app.logger.error(f"Unhandled exception: {e}", exc_info=True)
194
+ return jsonify({
195
+ "success": False,
196
+ "error": {
197
+ "message": "Internal server error",
198
+ "type": "InternalServerError"
199
+ }
200
+ }), 500
201
+
202
+ return app