mdviewer 0.2.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.
mdviewer/__init__.py ADDED
File without changes
mdviewer/app.py ADDED
@@ -0,0 +1,159 @@
1
+ import os
2
+
3
+ from bs4 import BeautifulSoup
4
+ from flask import Flask, abort, render_template, request, send_from_directory
5
+ from livereload import Server
6
+ from markdown_it import MarkdownIt
7
+
8
+ from mdviewer.search.fd_search import search_filenames
9
+ from mdviewer.search.rg_search import search_content
10
+
11
+ BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
12
+
13
+ app = Flask(
14
+ __name__,
15
+ template_folder=os.path.join(BASE_DIR, "templates"),
16
+ static_folder=os.path.join(BASE_DIR, "static"),
17
+ )
18
+ md = MarkdownIt()
19
+ MARKDOWN_ROOT = os.path.abspath(".")
20
+ EXCLUDED_DIRS = {'.git', '.venv', '__pycache__', 'node_modules', '.ruff_cache'}
21
+
22
+
23
+ # build_tree function to create a directory tree
24
+ def build_tree(root_dir):
25
+ tree = {}
26
+ for dirpath, dirnames, filenames in os.walk(root_dir):
27
+ rel_path = os.path.relpath(dirpath, root_dir)
28
+
29
+ # Exclude unwanted directories
30
+ dirnames[:] = [d for d in dirnames if d not in EXCLUDED_DIRS]
31
+
32
+ node = tree
33
+ if rel_path != ".":
34
+ for part in rel_path.split(os.sep):
35
+ node = node.setdefault(part, {})
36
+
37
+ for filename in filenames:
38
+ if filename.endswith(".md"):
39
+ node[filename] = os.path.relpath(
40
+ os.path.join(dirpath, filename), root_dir
41
+ )
42
+ return tree
43
+
44
+
45
+ # Flask routes
46
+ @app.route("/")
47
+ def index():
48
+ file_tree = build_tree(MARKDOWN_ROOT)
49
+ return render_template(
50
+ "index.html", tree=file_tree, initial_file=app.config.get("INITIAL_FILE")
51
+ )
52
+
53
+
54
+ @app.route("/files/<path:filepath>")
55
+ def serve_file(filepath):
56
+ full_path = os.path.join(MARKDOWN_ROOT, filepath)
57
+ if not os.path.isfile(full_path):
58
+ abort(404)
59
+ return send_from_directory(MARKDOWN_ROOT, filepath)
60
+
61
+
62
+ @app.route("/view/<path:filename>")
63
+ def view_markdown(filename):
64
+ full_path = os.path.abspath(os.path.join(MARKDOWN_ROOT, filename))
65
+
66
+ if not full_path.startswith(MARKDOWN_ROOT):
67
+ abort(403)
68
+
69
+ if os.path.isdir(full_path):
70
+ entries = []
71
+ for item in sorted(os.listdir(full_path)):
72
+ if item.startswith("."):
73
+ continue # Skip hidden files
74
+ item_path = os.path.join(filename, item)
75
+ if os.path.isdir(os.path.join(MARKDOWN_ROOT, item_path)):
76
+ entries.append((item + "/", item_path))
77
+ elif item.endswith(".md"):
78
+ entries.append((item, item_path))
79
+ return render_template(
80
+ "viewer.html",
81
+ folder=filename,
82
+ entries=entries,
83
+ content=None,
84
+ filename=filename,
85
+ )
86
+
87
+ elif os.path.isfile(full_path):
88
+ with open(full_path, encoding="utf-8") as f:
89
+ content_md = f.read()
90
+ html = md.render(content_md)
91
+
92
+ # Fix image paths
93
+ soup = BeautifulSoup(html, "html.parser")
94
+ for img in soup.find_all("img"):
95
+ src = img.get("src")
96
+ if src and not src.startswith("http") and not src.startswith("/files/"):
97
+ img_path = os.path.normpath(
98
+ os.path.join(os.path.dirname(filename), src)
99
+ )
100
+ img["src"] = f"/files/{img_path}"
101
+
102
+ return render_template(
103
+ "viewer.html", content=str(soup), entries=None, filename=filename
104
+ )
105
+
106
+ else:
107
+ abort(404)
108
+
109
+
110
+ # Search functionality
111
+ @app.route("/search")
112
+ def search():
113
+ query = request.args.get("q", "").strip()
114
+ mode = request.args.get("mode", "fd") # switch between fd or rg
115
+ results = []
116
+
117
+ if query:
118
+ if mode == "rg":
119
+ results = search_content(query, MARKDOWN_ROOT)
120
+ else:
121
+ results = search_filenames(query, MARKDOWN_ROOT)
122
+
123
+ return render_template("search.html", results=results, query=query, mode=mode)
124
+
125
+
126
+ def start_server(markdown_root, open_file=None):
127
+ global MARKDOWN_ROOT
128
+ MARKDOWN_ROOT = os.path.abspath(markdown_root)
129
+ app.config["INITIAL_FILE"] = open_file
130
+
131
+ server = Server(app.wsgi_app)
132
+
133
+ # 🔁 Watch templates, static files, and markdown
134
+ server.watch("templates/**/*.html")
135
+ server.watch("static/**/*.js")
136
+ server.watch("static/**/*.css")
137
+ server.watch("**/*.md")
138
+
139
+ # print(f"🔄 Serving http://127.0.0.1:5000")
140
+ if open_file:
141
+ print(f"📄 Opening file: {open_file}")
142
+
143
+ server.serve(port=5000, host="127.0.0.1", debug=True)
144
+
145
+
146
+ if __name__ == "__main__":
147
+ server = Server(app.wsgi_app)
148
+
149
+ # Watch all .md files starting from the project root
150
+ server.watch("**/*.md")
151
+
152
+ # Watch template changes
153
+ server.watch("templates/*.html")
154
+
155
+ # Optionally watch static JS/CSS too
156
+ server.watch("static/*.js")
157
+ # server.watch("static/*.css")
158
+
159
+ server.serve(port=5000, debug=True)
mdviewer/cli.py ADDED
@@ -0,0 +1,47 @@
1
+ import argparse
2
+ import os
3
+ import webbrowser
4
+ from mdviewer.app import start_server # make app.py expose a `start_server(path)`
5
+
6
+
7
+ def main():
8
+ parser = argparse.ArgumentParser(
9
+ prog="mdv", description="📘 mdviewer — GitHub-style Markdown viewer"
10
+ )
11
+ parser.add_argument(
12
+ "target", nargs="?", default="README.md", help="Markdown file or folder to view"
13
+ )
14
+ parser.add_argument(
15
+ "-o",
16
+ "--open",
17
+ action="store_true",
18
+ help="Open in browser after starting server (default: off)",
19
+ )
20
+
21
+ args = parser.parse_args()
22
+
23
+ # Expand ~ and resolve absolute path
24
+ target = os.path.abspath(os.path.expanduser(args.target))
25
+
26
+ if not os.path.exists(target):
27
+ print(f"❌ Error: Path not found: {target}")
28
+ return
29
+
30
+ # Determine root (folder) and optional file
31
+ root = target if os.path.isdir(target) else os.path.dirname(target)
32
+ open_file = target if os.path.isfile(target) else None
33
+
34
+ print(f"📂 Serving: {root}")
35
+
36
+ start_server(markdown_root=root, open_file=open_file)
37
+
38
+ print("🌐 Open in browser: http://127.0.0.1:5000")
39
+ print("🛑 Press Ctrl-C to stop server.")
40
+ print("❗ Remember to close the browser tab before restarting.")
41
+
42
+ if args.open:
43
+ webbrowser.open_new_tab("http://127.0.0.1:5000")
44
+
45
+
46
+ if __name__ == "__main__":
47
+ main()
File without changes
@@ -0,0 +1,16 @@
1
+ import subprocess
2
+ import os
3
+
4
+
5
+ def search_filenames(query, root):
6
+ try:
7
+ result = subprocess.run(
8
+ ["fd", "--type", "f", "--extension", "md", query, root],
9
+ capture_output=True,
10
+ text=True,
11
+ )
12
+ matches = result.stdout.strip().split("\n")
13
+ return [os.path.relpath(m, root) for m in matches if m]
14
+ except Exception as e:
15
+ print("[FD ERROR]", e)
16
+ return []
@@ -0,0 +1,16 @@
1
+ import subprocess
2
+ import os
3
+
4
+
5
+ def search_content(query, root):
6
+ try:
7
+ result = subprocess.run(
8
+ ["rg", "--files-with-matches", "-i", "-t", "md", query, root],
9
+ capture_output=True,
10
+ text=True,
11
+ )
12
+ matches = result.stdout.strip().split("\n")
13
+ return [os.path.relpath(m, root) for m in matches if m]
14
+ except Exception as e:
15
+ print("[RG ERROR]", e)
16
+ return []
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: mdviewer
3
+ Version: 0.2.0
4
+ Summary: GitHub-style Markdown viewer
5
+ Author-email: Biao Jiang <github@minsignal.com>
6
+ License: MIT
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: flask
9
+ Requires-Dist: livereload
10
+ Requires-Dist: markdown-it-py
11
+ Requires-Dist: beautifulsoup4
12
+
13
+ # 📝 Markdown Viewer (GitHub-Style)
14
+
15
+ [![PyPI](https://img.shields.io/pypi/v/mdviewer.svg)](https://pypi.org/project/mdviewer)
16
+ [![Homebrew](https://img.shields.io/badge/Homebrew-mdviewer-blue)](https://github.com/yourusername/homebrew-mdviewer)
17
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
18
+
19
+ A GitHub-style Markdown viewer for local docs, with file tree, search, and live reload.
20
+
21
+ A local Markdown documentation browser that:
22
+
23
+ - Renders `.md` files with GitHub-flavored styles
24
+ - Displays a recursive file/folder tree
25
+ - Supports live plain-text filtering (client-side)
26
+ - Supports filename/content search using `fd` or `rg`
27
+ - Auto-reloads edited files
28
+ - Dark/light mode toggle
29
+ - Export/Print to PDF
30
+ - Breadcrumb navigation with folder/file icons
31
+ - Highlights current file in tree and auto-expands
32
+
33
+ ## 🚀 Installation
34
+
35
+ ### 🔧 Option 1: Homebrew (macOS/Linux)
36
+
37
+ ```bash
38
+ brew tap biaojiang/mdviewer
39
+ brew install mdviewer
40
+ ```
41
+
42
+ ### 🐍 Option 2: Python (via pip)
43
+
44
+ ```
45
+ pip install mdviewer
46
+ ```
47
+
48
+ **Optionally add an alias:**
49
+
50
+ ```bash
51
+ echo 'alias mdv="mdviewer"' >> ~/.zshrc
52
+ source ~/.zshrc
53
+ # or symlink
54
+ sudo ln -s $(which mdviewer) /usr/local/bin/mdv
55
+ ```
56
+
57
+ ### 🔧 Option 3: Build from Source
58
+
59
+ ```sh
60
+ # Install Python deps
61
+ pip install -r requirements.txt
62
+
63
+ # Optional: use a venv
64
+ python -m venv .venv
65
+ source .venv/bin/activate
66
+
67
+ # Install tools if needing advanced search
68
+ brew install fd ripgrep
69
+ # or
70
+ sudo apt install fd-find ripgrep
71
+
72
+ # ▶️ Run the Server
73
+ python app.py
74
+
75
+ Open [http://127.0.0.1:5000](http://127.0.0.1:5000/) in your browser.
76
+ ```
77
+
78
+ ---
79
+
80
+ ## 🚀 Features
81
+
82
+ - ✅ GitHub-style rendering via `markdown-it-py` + GitHub CSS
83
+ - ✅ Auto-expandable file tree using `<details>`
84
+ - ✅ Live filtering with reset button
85
+ - ✅ Backend-powered search:
86
+ - `fd`: fuzzy filename matching
87
+ - `rg`: content search
88
+ - ✅ Flask-based local webserver
89
+ - ✅ MathJax support for LaTeX
90
+ - ✅ Reload current buffer on changes (with `livereload`)
91
+ - ✅ Font Awesome icons for folders/files
92
+ - ✅ Breadcrumb that reflects navigation path
93
+ - ✅ Highlight + auto-expand tree for active file
94
+ - ✅ PDF/Print export button with clean print CSS
95
+
96
+ ---
97
+
98
+ ## 📁 File Structure
99
+
100
+ ```text
101
+ .
102
+ ├── docs
103
+ │ └── math
104
+ │ └── math-test.md
105
+ ├── pyproject.toml
106
+ ├── README.md
107
+ ├── requirements.txt
108
+ ├── screenshot.png
109
+ ├── src
110
+ │ ├── mdviewer
111
+ │ │ ├── __init__.py
112
+ │ │ ├── app.py
113
+ │ │ ├── cli.py
114
+ │ │ └── search
115
+ ├── static
116
+ │ ├── script.js
117
+ │ └── style.css
118
+ └── templates
119
+ ├── index.html
120
+ ├── search.html
121
+ └── viewer.html
122
+ ```
123
+
124
+ ---
125
+
126
+ ## 🔍 Search Modes
127
+
128
+ - `fd`: fuzzy filename match (fast)
129
+ - `rg`: full-text content match (powerful)
130
+
131
+ ---
132
+
133
+ ## ⚙️ Keyboard & UI
134
+
135
+ - 🌓 Dark/light toggle
136
+ - ⌨️ Live tree filter with reset
137
+ - 🗂 Expandable nested folders
138
+ - 🔗 Click to render `.md` file in browser
139
+ - 🖨 Export/Print to PDF button
140
+ - 📁 Breadcrumb with Font Awesome icons
141
+ - 📄 Highlight + expand tree for active file
142
+
143
+ ---
144
+
145
+ ## ⏭️ Next Steps
146
+
147
+ -
148
+
149
+ ---
150
+
151
+ ## 📄 License
152
+
153
+ MIT
@@ -0,0 +1,11 @@
1
+ mdviewer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ mdviewer/app.py,sha256=PjUKTAH9EQVQKsOKpRWxQ4iHYv4snSn6uTUWL2k9ekM,4739
3
+ mdviewer/cli.py,sha256=29xv4X0TNWYXRAIsXQJInojn7WSmq4NwtGukxti_OJo,1354
4
+ mdviewer/search/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ mdviewer/search/fd_search.py,sha256=iIohgsG3S_z_24g6vogAAVgIwdtn8ApalDjEcMrTfrg,434
6
+ mdviewer/search/rg_search.py,sha256=I4gwbNDLMUciOiOp4gGbkALfkD1bpU1261AuBalDEIw,438
7
+ mdviewer-0.2.0.dist-info/METADATA,sha256=SOyRz6HhpTro48o84ZFyoHd5XHiGZyL6IrUgwZwnoFk,3447
8
+ mdviewer-0.2.0.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
9
+ mdviewer-0.2.0.dist-info/entry_points.txt,sha256=U5yxbTo6maxj6cf4xTM1kxaKR406ul4m25eCZn05lzc,47
10
+ mdviewer-0.2.0.dist-info/top_level.txt,sha256=Suu9lMIhaz8aJV0UI3_nihKY6t_fIAp2kxXxRHTZl6Q,9
11
+ mdviewer-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.7.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mdviewer = mdviewer.cli:main
@@ -0,0 +1 @@
1
+ mdviewer