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 +0 -0
- mdviewer/app.py +159 -0
- mdviewer/cli.py +47 -0
- mdviewer/search/__init__.py +0 -0
- mdviewer/search/fd_search.py +16 -0
- mdviewer/search/rg_search.py +16 -0
- mdviewer-0.2.0.dist-info/METADATA +153 -0
- mdviewer-0.2.0.dist-info/RECORD +11 -0
- mdviewer-0.2.0.dist-info/WHEEL +5 -0
- mdviewer-0.2.0.dist-info/entry_points.txt +2 -0
- mdviewer-0.2.0.dist-info/top_level.txt +1 -0
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
|
+
[](https://pypi.org/project/mdviewer)
|
|
16
|
+
[](https://github.com/yourusername/homebrew-mdviewer)
|
|
17
|
+
[](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 @@
|
|
|
1
|
+
mdviewer
|