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.
- markdown_viewer/__init__.py +21 -0
- markdown_viewer/__main__.py +148 -0
- markdown_viewer/app.py +202 -0
- markdown_viewer/cli.py +630 -0
- markdown_viewer/electron/main.js +261 -0
- markdown_viewer/electron/package.json +51 -0
- markdown_viewer/electron/preload.js +91 -0
- markdown_viewer/electron/renderer/index.html +170 -0
- markdown_viewer/electron/renderer/scripts/api.js +128 -0
- markdown_viewer/electron/renderer/scripts/app.js +404 -0
- markdown_viewer/electron/renderer/scripts/browser-shim.js +149 -0
- markdown_viewer/electron/renderer/scripts/renderer.js +143 -0
- markdown_viewer/electron/renderer/styles/main.css +447 -0
- markdown_viewer/exporters/__init__.py +1 -0
- markdown_viewer/exporters/pdf_exporter.py +198 -0
- markdown_viewer/exporters/word_exporter.py +141 -0
- markdown_viewer/processors/__init__.py +1 -0
- markdown_viewer/processors/markdown_processor.py +208 -0
- markdown_viewer/routes.py +529 -0
- markdown_viewer/server.py +69 -0
- markdown_viewer/setup.py +151 -0
- markdown_viewer/translators/__init__.py +1 -0
- markdown_viewer/translators/content_translator.py +164 -0
- markdown_viewer/utils/__init__.py +1 -0
- markdown_viewer/utils/file_handler.py +118 -0
- markdown_viewer_app-1.0.0.dist-info/LICENSE +21 -0
- markdown_viewer_app-1.0.0.dist-info/METADATA +970 -0
- markdown_viewer_app-1.0.0.dist-info/RECORD +30 -0
- markdown_viewer_app-1.0.0.dist-info/WHEEL +4 -0
- markdown_viewer_app-1.0.0.dist-info/entry_points.txt +5 -0
|
@@ -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
|