leafdocs 0.1.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.
leafdocs/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .app import LeafDocs
2
+
3
+ __all__ = ["LeafDocs"]
leafdocs/app.py ADDED
@@ -0,0 +1,178 @@
1
+ import os
2
+ import pathlib
3
+ import hashlib
4
+ import secrets
5
+
6
+ import bcrypt
7
+ import frontmatter
8
+ import markdown
9
+ from dotenv import load_dotenv
10
+ from flask import (
11
+ Flask,
12
+ abort,
13
+ redirect,
14
+ render_template,
15
+ request,
16
+ session,
17
+ url_for,
18
+ )
19
+
20
+ load_dotenv()
21
+
22
+
23
+ def _load_pins() -> list[bytes]:
24
+ """Read LEAFDOCS_PINS from env, hash each with bcrypt, return list of hashes."""
25
+ raw = os.getenv("LEAFDOCS_PINS", "").strip()
26
+ if not raw:
27
+ return []
28
+ pins = [p.strip() for p in raw.split(",") if p.strip()]
29
+ return [bcrypt.hashpw(p.encode(), bcrypt.gensalt()) for p in pins]
30
+
31
+
32
+ def _verify_pin(candidate: str, hashes: list[bytes]) -> bool:
33
+ return any(bcrypt.checkpw(candidate.encode(), h) for h in hashes)
34
+
35
+
36
+ def _slug(filepath: pathlib.Path) -> str:
37
+ """Convert a .md filepath to its URL slug (stem only)."""
38
+ return filepath.stem
39
+
40
+
41
+ def _discover_docs(docs_dir: pathlib.Path) -> list[dict]:
42
+ """
43
+ Scan docs_dir for .md files. Return list of dicts with:
44
+ slug, title, tags, path
45
+ Sorted alphabetically by title.
46
+ """
47
+ docs = []
48
+ for md_file in sorted(docs_dir.glob("*.md")):
49
+ post = frontmatter.load(str(md_file))
50
+ title = post.get("title") or md_file.stem.replace("-", " ").replace("_", " ").title()
51
+ tags = post.get("tags") or []
52
+ if isinstance(tags, str):
53
+ tags = [t.strip() for t in tags.split(",")]
54
+ docs.append(
55
+ {
56
+ "slug": _slug(md_file),
57
+ "title": title,
58
+ "tags": tags,
59
+ "path": md_file,
60
+ }
61
+ )
62
+ docs.sort(key=lambda d: d["title"].lower())
63
+ return docs
64
+
65
+
66
+ class LeafDocs:
67
+ def __init__(
68
+ self,
69
+ docs_dir: str = "./docs",
70
+ secret_key: str | None = None,
71
+ ):
72
+ self.docs_dir = pathlib.Path(docs_dir).resolve()
73
+ if not self.docs_dir.exists():
74
+ raise FileNotFoundError(f"docs_dir not found: {self.docs_dir}")
75
+
76
+ self._pin_hashes = _load_pins()
77
+ self._auth_enabled = bool(self._pin_hashes)
78
+
79
+ self.flask_app = Flask(__name__)
80
+ self.flask_app.secret_key = secret_key or os.getenv("LEAFDOCS_SECRET_KEY") or secrets.token_hex(32)
81
+
82
+ self._register_routes()
83
+
84
+ # ------------------------------------------------------------------
85
+ # Internal helpers
86
+ # ------------------------------------------------------------------
87
+
88
+ def _is_authenticated(self) -> bool:
89
+ if not self._auth_enabled:
90
+ return True
91
+ return session.get("authenticated") is True
92
+
93
+ def _require_auth(self):
94
+ if not self._is_authenticated():
95
+ return redirect(url_for("login"))
96
+ return None
97
+
98
+ # ------------------------------------------------------------------
99
+ # Route registration
100
+ # ------------------------------------------------------------------
101
+
102
+ def _register_routes(self):
103
+ app = self.flask_app
104
+
105
+ # Attach self so inner functions can reference it
106
+ leafdocs = self
107
+
108
+ @app.route("/login", methods=["GET", "POST"])
109
+ def login():
110
+ if not leafdocs._auth_enabled:
111
+ return redirect(url_for("index"))
112
+
113
+ error = None
114
+ if request.method == "POST":
115
+ pin = request.form.get("pin", "")
116
+ if _verify_pin(pin, leafdocs._pin_hashes):
117
+ session["authenticated"] = True
118
+ session.permanent = False
119
+ return redirect(url_for("index"))
120
+ error = "Incorrect pin."
121
+
122
+ return render_template("login.html", error=error)
123
+
124
+ @app.route("/logout")
125
+ def logout():
126
+ session.clear()
127
+ return redirect(url_for("login"))
128
+
129
+ @app.route("/")
130
+ def index():
131
+ redir = leafdocs._require_auth()
132
+ if redir:
133
+ return redir
134
+ docs = _discover_docs(leafdocs.docs_dir)
135
+ return render_template("index.html", docs=docs, auth_enabled=leafdocs._auth_enabled)
136
+
137
+ @app.route("/<slug>")
138
+ def reader(slug: str):
139
+ redir = leafdocs._require_auth()
140
+ if redir:
141
+ return redir
142
+
143
+ # Find matching file
144
+ md_file = leafdocs.docs_dir / f"{slug}.md"
145
+ if not md_file.exists():
146
+ abort(404)
147
+
148
+ # Security: ensure resolved path is still inside docs_dir
149
+ try:
150
+ md_file.resolve().relative_to(leafdocs.docs_dir)
151
+ except ValueError:
152
+ abort(403)
153
+
154
+ post = frontmatter.load(str(md_file))
155
+ title = post.get("title") or md_file.stem.replace("-", " ").replace("_", " ").title()
156
+ tags = post.get("tags") or []
157
+ if isinstance(tags, str):
158
+ tags = [t.strip() for t in tags.split(",")]
159
+
160
+ html_content = markdown.markdown(
161
+ post.content,
162
+ extensions=["fenced_code", "tables", "toc", "nl2br"],
163
+ )
164
+
165
+ return render_template(
166
+ "reader.html",
167
+ title=title,
168
+ tags=tags,
169
+ content=html_content,
170
+ auth_enabled=leafdocs._auth_enabled,
171
+ )
172
+
173
+ # ------------------------------------------------------------------
174
+ # Public API
175
+ # ------------------------------------------------------------------
176
+
177
+ def run(self, host: str = "127.0.0.1", port: int = 5000, **kwargs):
178
+ self.flask_app.run(host=host, port=port, **kwargs)
Binary file
@@ -0,0 +1,94 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/x-icon" href="/static/favicon.ico">
7
+ <title>{% block title %}LeafDocs{% endblock %}</title>
8
+ <style>
9
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
10
+
11
+ :root {
12
+ --bg: #0f0f0f;
13
+ --surface: #181818;
14
+ --border: #2a2a2a;
15
+ --text: #e8e8e8;
16
+ --muted: #888;
17
+ --accent: #22c55e;
18
+ --accent-dim:#14532d;
19
+ --tag-bg: #0f2e1a;
20
+ --tag-text: #86efac;
21
+ --radius: 6px;
22
+ --font-body: 'Georgia', serif;
23
+ --font-ui: system-ui, -apple-system, sans-serif;
24
+ }
25
+
26
+ html, body {
27
+ background: var(--bg);
28
+ color: var(--text);
29
+ font-family: var(--font-ui);
30
+ min-height: 100vh;
31
+ line-height: 1.6;
32
+ }
33
+
34
+ a { color: var(--accent); text-decoration: none; }
35
+ a:hover { text-decoration: underline; }
36
+
37
+ .nav {
38
+ display: flex;
39
+ align-items: center;
40
+ justify-content: space-between;
41
+ padding: 0.85rem 2rem;
42
+ border-bottom: 1px solid var(--border);
43
+ background: var(--surface);
44
+ }
45
+ .nav-brand {
46
+ font-weight: 700;
47
+ font-size: 1rem;
48
+ color: var(--text);
49
+ letter-spacing: 0.04em;
50
+ }
51
+ .nav-brand span { color: var(--accent); }
52
+ .nav-right { display: flex; gap: 1rem; align-items: center; }
53
+ .nav-link {
54
+ font-size: 0.82rem;
55
+ color: var(--muted);
56
+ transition: color 0.15s;
57
+ }
58
+ .nav-link:hover { color: var(--text); text-decoration: none; }
59
+
60
+ .container {
61
+ max-width: 860px;
62
+ margin: 0 auto;
63
+ padding: 2.5rem 1.5rem;
64
+ }
65
+
66
+ .tag {
67
+ display: inline-block;
68
+ background: var(--tag-bg);
69
+ color: var(--tag-text);
70
+ font-size: 0.72rem;
71
+ font-weight: 500;
72
+ padding: 0.15rem 0.55rem;
73
+ border-radius: 99px;
74
+ border: 1px solid var(--accent-dim);
75
+ letter-spacing: 0.03em;
76
+ }
77
+ </style>
78
+ {% block head %}{% endblock %}
79
+ </head>
80
+ <body>
81
+ <nav class="nav">
82
+ <a href="/" class="nav-brand">Leaf<span>Docs</span></a>
83
+ <div class="nav-right">
84
+ {% if auth_enabled is defined and auth_enabled %}
85
+ <a href="/logout" class="nav-link">logout</a>
86
+ {% endif %}
87
+ {% block nav_extra %}{% endblock %}
88
+ </div>
89
+ </nav>
90
+ <div class="container">
91
+ {% block content %}{% endblock %}
92
+ </div>
93
+ </body>
94
+ </html>
@@ -0,0 +1,141 @@
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}LeafDocs — Index{% endblock %}
4
+
5
+ {% block head %}
6
+ <style>
7
+ .page-header {
8
+ margin-bottom: 2rem;
9
+ }
10
+ .page-header h1 {
11
+ font-size: 1.5rem;
12
+ font-weight: 600;
13
+ margin-bottom: 0.4rem;
14
+ color: var(--text);
15
+ }
16
+ .page-header p {
17
+ color: var(--muted);
18
+ font-size: 0.88rem;
19
+ }
20
+
21
+ .search-wrap {
22
+ position: relative;
23
+ margin-bottom: 1.75rem;
24
+ }
25
+ .search-icon {
26
+ position: absolute;
27
+ left: 0.85rem;
28
+ top: 50%;
29
+ transform: translateY(-50%);
30
+ color: var(--muted);
31
+ pointer-events: none;
32
+ font-size: 0.9rem;
33
+ }
34
+ #search {
35
+ width: 100%;
36
+ background: var(--surface);
37
+ border: 1px solid var(--border);
38
+ color: var(--text);
39
+ font-family: var(--font-ui);
40
+ font-size: 0.92rem;
41
+ padding: 0.65rem 1rem 0.65rem 2.4rem;
42
+ border-radius: var(--radius);
43
+ outline: none;
44
+ transition: border-color 0.15s;
45
+ }
46
+ #search:focus { border-color: var(--accent); }
47
+ #search::placeholder { color: var(--muted); }
48
+
49
+ #doc-list { list-style: none; }
50
+
51
+ .doc-item {
52
+ display: flex;
53
+ align-items: baseline;
54
+ gap: 0.75rem;
55
+ padding: 0.75rem 0;
56
+ border-bottom: 1px solid var(--border);
57
+ transition: background 0.1s;
58
+ }
59
+ .doc-item:last-child { border-bottom: none; }
60
+
61
+ .doc-title {
62
+ font-size: 0.95rem;
63
+ font-weight: 500;
64
+ color: var(--text);
65
+ flex: 1;
66
+ min-width: 0;
67
+ white-space: nowrap;
68
+ overflow: hidden;
69
+ text-overflow: ellipsis;
70
+ }
71
+ .doc-title:hover { color: var(--accent); text-decoration: none; }
72
+
73
+ .doc-tags { display: flex; gap: 0.4rem; flex-wrap: wrap; flex-shrink: 0; }
74
+
75
+ #empty-state {
76
+ display: none;
77
+ text-align: center;
78
+ padding: 3rem 0;
79
+ color: var(--muted);
80
+ font-size: 0.88rem;
81
+ }
82
+ </style>
83
+ {% endblock %}
84
+
85
+ {% block content %}
86
+ <div class="page-header">
87
+ <h1>Documents</h1>
88
+ <p>{{ docs | length }} file{{ 's' if docs | length != 1 else '' }}</p>
89
+ </div>
90
+
91
+ <div class="search-wrap">
92
+ <span class="search-icon">⌕</span>
93
+ <input
94
+ type="search"
95
+ id="search"
96
+ placeholder="Search by title or tag…"
97
+ autocomplete="off"
98
+ autofocus
99
+ />
100
+ </div>
101
+
102
+ <ul id="doc-list">
103
+ {% for doc in docs %}
104
+ <li
105
+ class="doc-item"
106
+ data-title="{{ doc.title | lower }}"
107
+ data-tags="{{ doc.tags | join(' ') | lower }}"
108
+ >
109
+ <a href="/{{ doc.slug }}" class="doc-title">{{ doc.title }}</a>
110
+ {% if doc.tags %}
111
+ <div class="doc-tags">
112
+ {% for tag in doc.tags %}
113
+ <span class="tag">{{ tag }}</span>
114
+ {% endfor %}
115
+ </div>
116
+ {% endif %}
117
+ </li>
118
+ {% endfor %}
119
+ </ul>
120
+
121
+ <div id="empty-state">No documents match your search.</div>
122
+
123
+ <script>
124
+ const input = document.getElementById('search');
125
+ const items = Array.from(document.querySelectorAll('.doc-item'));
126
+ const empty = document.getElementById('empty-state');
127
+
128
+ input.addEventListener('input', () => {
129
+ const q = input.value.trim().toLowerCase();
130
+ let visible = 0;
131
+ items.forEach(item => {
132
+ const match = !q
133
+ || item.dataset.title.includes(q)
134
+ || item.dataset.tags.includes(q);
135
+ item.style.display = match ? '' : 'none';
136
+ if (match) visible++;
137
+ });
138
+ empty.style.display = visible === 0 ? 'block' : 'none';
139
+ });
140
+ </script>
141
+ {% endblock %}
@@ -0,0 +1,108 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/x-icon" href="/static/favicon.ico">
7
+ <title>LeafDocs — Login</title>
8
+ <style>
9
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
10
+ :root {
11
+ --bg: #0f0f0f;
12
+ --surface: #181818;
13
+ --border: #2a2a2a;
14
+ --text: #e8e8e8;
15
+ --muted: #888;
16
+ --accent: #22c55e;
17
+ --radius: 6px;
18
+ }
19
+ html, body {
20
+ background: var(--bg);
21
+ color: var(--text);
22
+ font-family: system-ui, -apple-system, sans-serif;
23
+ min-height: 100vh;
24
+ display: flex;
25
+ align-items: center;
26
+ justify-content: center;
27
+ }
28
+ .login-box {
29
+ width: 100%;
30
+ max-width: 360px;
31
+ padding: 2.5rem;
32
+ background: var(--surface);
33
+ border: 1px solid var(--border);
34
+ border-radius: 10px;
35
+ }
36
+ .login-brand {
37
+ font-size: 1.1rem;
38
+ font-weight: 700;
39
+ letter-spacing: 0.04em;
40
+ margin-bottom: 1.75rem;
41
+ color: var(--text);
42
+ }
43
+ .login-brand span { color: var(--accent); }
44
+
45
+ label {
46
+ display: block;
47
+ font-size: 0.8rem;
48
+ color: var(--muted);
49
+ margin-bottom: 0.4rem;
50
+ letter-spacing: 0.04em;
51
+ text-transform: uppercase;
52
+ }
53
+ input[type="password"] {
54
+ width: 100%;
55
+ background: var(--bg);
56
+ border: 1px solid var(--border);
57
+ color: var(--text);
58
+ font-size: 1rem;
59
+ padding: 0.65rem 0.85rem;
60
+ border-radius: var(--radius);
61
+ outline: none;
62
+ margin-bottom: 1.1rem;
63
+ transition: border-color 0.15s;
64
+ }
65
+ input[type="password"]:focus { border-color: var(--accent); }
66
+
67
+ .error {
68
+ font-size: 0.82rem;
69
+ color: #f87171;
70
+ margin-bottom: 0.85rem;
71
+ }
72
+
73
+ button {
74
+ width: 100%;
75
+ background: var(--accent);
76
+ color: #fff;
77
+ font-size: 0.92rem;
78
+ font-weight: 600;
79
+ border: none;
80
+ padding: 0.7rem;
81
+ border-radius: var(--radius);
82
+ cursor: pointer;
83
+ transition: opacity 0.15s;
84
+ }
85
+ button:hover { opacity: 0.88; }
86
+ </style>
87
+ </head>
88
+ <body>
89
+ <div class="login-box">
90
+ <div class="login-brand">Leaf<span>Docs</span></div>
91
+ <form method="POST">
92
+ <label for="pin">Pin</label>
93
+ <input
94
+ type="password"
95
+ id="pin"
96
+ name="pin"
97
+ placeholder="Enter pin"
98
+ autofocus
99
+ autocomplete="current-password"
100
+ />
101
+ {% if error %}
102
+ <div class="error">{{ error }}</div>
103
+ {% endif %}
104
+ <button type="submit">Unlock</button>
105
+ </form>
106
+ </div>
107
+ </body>
108
+ </html>
@@ -0,0 +1,145 @@
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}{{ title }} — LeafDocs{% endblock %}
4
+
5
+ {% block head %}
6
+ <style>
7
+ .reader-header {
8
+ margin-bottom: 2.25rem;
9
+ padding-bottom: 1.25rem;
10
+ border-bottom: 1px solid var(--border);
11
+ }
12
+ .reader-back {
13
+ display: inline-flex;
14
+ align-items: center;
15
+ gap: 0.3rem;
16
+ font-size: 0.82rem;
17
+ color: var(--muted);
18
+ margin-bottom: 1rem;
19
+ transition: color 0.15s;
20
+ }
21
+ .reader-back:hover { color: var(--text); text-decoration: none; }
22
+
23
+ .reader-title {
24
+ font-size: 1.9rem;
25
+ font-weight: 700;
26
+ line-height: 1.25;
27
+ color: var(--text);
28
+ margin-bottom: 0.75rem;
29
+ }
30
+ .reader-tags { display: flex; gap: 0.45rem; flex-wrap: wrap; }
31
+
32
+ /* Markdown content */
33
+ .prose {
34
+ font-family: var(--font-body);
35
+ font-size: 1.05rem;
36
+ line-height: 1.8;
37
+ color: #d4d4d4;
38
+ max-width: 720px;
39
+ }
40
+
41
+ .prose h1, .prose h2, .prose h3,
42
+ .prose h4, .prose h5, .prose h6 {
43
+ font-family: var(--font-ui);
44
+ color: var(--text);
45
+ margin: 2rem 0 0.75rem;
46
+ line-height: 1.3;
47
+ }
48
+ .prose h1 { font-size: 1.6rem; }
49
+ .prose h2 { font-size: 1.3rem; border-bottom: 1px solid var(--border); padding-bottom: 0.4rem; }
50
+ .prose h3 { font-size: 1.1rem; }
51
+
52
+ .prose p { margin: 0 0 1.1rem; }
53
+
54
+ .prose a { color: var(--accent); }
55
+ .prose a:hover { text-decoration: underline; }
56
+
57
+ .prose ul, .prose ol {
58
+ margin: 0 0 1.1rem 1.5rem;
59
+ }
60
+ .prose li { margin-bottom: 0.3rem; }
61
+
62
+ .prose blockquote {
63
+ margin: 1.25rem 0;
64
+ padding: 0.75rem 1.25rem;
65
+ border-left: 3px solid var(--accent);
66
+ background: var(--surface);
67
+ color: var(--muted);
68
+ border-radius: 0 var(--radius) var(--radius) 0;
69
+ }
70
+
71
+ .prose code {
72
+ background: var(--surface);
73
+ border: 1px solid var(--border);
74
+ padding: 0.1em 0.4em;
75
+ border-radius: 4px;
76
+ font-size: 0.88em;
77
+ font-family: 'Menlo', 'Consolas', monospace;
78
+ color: #c9b4fa;
79
+ }
80
+
81
+ .prose pre {
82
+ background: var(--surface);
83
+ border: 1px solid var(--border);
84
+ border-radius: var(--radius);
85
+ padding: 1.1rem 1.25rem;
86
+ overflow-x: auto;
87
+ margin: 0 0 1.25rem;
88
+ }
89
+ .prose pre code {
90
+ background: none;
91
+ border: none;
92
+ padding: 0;
93
+ font-size: 0.875rem;
94
+ color: #c9b4fa;
95
+ }
96
+
97
+ .prose table {
98
+ width: 100%;
99
+ border-collapse: collapse;
100
+ margin: 0 0 1.25rem;
101
+ font-size: 0.92rem;
102
+ }
103
+ .prose th, .prose td {
104
+ text-align: left;
105
+ padding: 0.5rem 0.75rem;
106
+ border: 1px solid var(--border);
107
+ }
108
+ .prose th {
109
+ background: var(--surface);
110
+ font-family: var(--font-ui);
111
+ font-weight: 600;
112
+ color: var(--text);
113
+ }
114
+
115
+ .prose hr {
116
+ border: none;
117
+ border-top: 1px solid var(--border);
118
+ margin: 2rem 0;
119
+ }
120
+
121
+ .prose img {
122
+ max-width: 100%;
123
+ border-radius: var(--radius);
124
+ margin: 1rem 0;
125
+ }
126
+ </style>
127
+ {% endblock %}
128
+
129
+ {% block content %}
130
+ <div class="reader-header">
131
+ <a href="/" class="reader-back">← All documents</a>
132
+ <h1 class="reader-title">{{ title }}</h1>
133
+ {% if tags %}
134
+ <div class="reader-tags">
135
+ {% for tag in tags %}
136
+ <span class="tag">{{ tag }}</span>
137
+ {% endfor %}
138
+ </div>
139
+ {% endif %}
140
+ </div>
141
+
142
+ <div class="prose">
143
+ {{ content | safe }}
144
+ </div>
145
+ {% endblock %}
@@ -0,0 +1,310 @@
1
+ Metadata-Version: 2.4
2
+ Name: leafdocs
3
+ Version: 0.1.0
4
+ Summary: Turn a directory of Markdown files into a self-hosted, searchable web reader.
5
+ Author-email: Anshul <you@example.com>
6
+ License: MIT
7
+ Keywords: markdown,docs,flask,self-hosted
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Framework :: Flask
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: flask>=3.0
16
+ Requires-Dist: markdown>=3.5
17
+ Requires-Dist: python-frontmatter>=1.1
18
+ Requires-Dist: bcrypt>=4.1
19
+ Requires-Dist: python-dotenv>=1.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest; extra == "dev"
22
+ Requires-Dist: pytest-flask; extra == "dev"
23
+ Dynamic: license-file
24
+
25
+ # leafdocs
26
+
27
+ A lightweight Python library that turns a directory of Markdown files into a self-hosted, searchable web reader.
28
+
29
+ Install it, point it at a folder, get a running Flask app you can deploy anywhere.
30
+
31
+ ```python
32
+ from leafdocs import LeafDocs
33
+
34
+ ld = LeafDocs(docs_dir="./docs")
35
+ ld.run()
36
+ ```
37
+
38
+ ---
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ pip install leafdocs
44
+ ```
45
+
46
+ ---
47
+
48
+ ## Usage
49
+
50
+ ### Minimal setup
51
+
52
+ ```python
53
+ from leafdocs import LeafDocs
54
+
55
+ ld = LeafDocs(docs_dir="./docs")
56
+ ld.run()
57
+ ```
58
+
59
+ Visit `http://127.0.0.1:5000` — you'll see a searchable index of all `.md` files in your `docs/` directory.
60
+
61
+ ### Constructor arguments
62
+
63
+ | Argument | Type | Default | Description |
64
+ |--------------|-----------------|-----------------|--------------------------------------------------|
65
+ | `docs_dir` | `str` | `"./docs"` | Path to the directory containing `.md` files |
66
+ | `secret_key` | `str` or `None` | `None` | Flask session secret key (see Auth section) |
67
+
68
+ ### Accessing the Flask app
69
+
70
+ The underlying Flask app is exposed as `ld.flask_app`. Use it to add routes, middleware, or blueprints:
71
+
72
+ ```python
73
+ from leafdocs import LeafDocs
74
+
75
+ ld = LeafDocs(docs_dir="./docs")
76
+
77
+ @ld.flask_app.route("/health")
78
+ def health():
79
+ return {"status": "ok"}
80
+
81
+ ld.run()
82
+ ```
83
+
84
+ ---
85
+
86
+ ## Frontmatter
87
+
88
+ Each `.md` file can optionally include YAML frontmatter. Supported fields:
89
+
90
+ ```yaml
91
+ ---
92
+ title: My Document Title
93
+ tags: [python, tutorial]
94
+ ---
95
+ ```
96
+
97
+ | Field | Type | Fallback |
98
+ |---------|--------------------------------|----------------------|
99
+ | `title` | string | Filename, titlecased |
100
+ | `tags` | list or comma-separated string | None |
101
+
102
+ Frontmatter is optional — files without it are discovered and served normally.
103
+
104
+ ---
105
+
106
+ ## Authentication
107
+
108
+ By default the server runs open with no login required.
109
+
110
+ To enable pin-based auth, add pins to a `.env` file in your working directory:
111
+
112
+ ```
113
+ LEAFDOCS_PINS=mypin123,anotherpin
114
+ ```
115
+
116
+ Restart the server. All routes will now require a valid pin.
117
+
118
+ **How it works:**
119
+ - Pins are hashed with bcrypt at startup — raw values are never stored in memory
120
+ - A successful login issues an `httponly` session cookie
121
+ - Multiple pins are supported — useful for sharing access without a shared secret
122
+ - To invalidate all sessions, rotate or remove the pin and restart
123
+
124
+ ### Session secret key
125
+
126
+ By default a random secret key is generated at startup, which means sessions are invalidated on every restart. For persistent sessions across restarts, set a stable key:
127
+
128
+ ```
129
+ LEAFDOCS_SECRET_KEY=your-long-random-secret-here
130
+ ```
131
+
132
+ Or pass it directly in code:
133
+
134
+ ```python
135
+ ld = LeafDocs(docs_dir="./docs", secret_key="your-long-random-secret-here")
136
+ ```
137
+
138
+ Generate a good key with:
139
+
140
+ ```bash
141
+ python -c "import secrets; print(secrets.token_hex(32))"
142
+ ```
143
+
144
+ ---
145
+
146
+ ## Configuration reference
147
+
148
+ All pins and the secret key are configured via `.env` only. Copy `.env.example` to `.env` to get started:
149
+
150
+ ```
151
+ LEAFDOCS_PINS=pin1,pin2
152
+ LEAFDOCS_SECRET_KEY=your-secret-here
153
+ ```
154
+
155
+ ---
156
+
157
+ ## Known limitations
158
+
159
+ - **No caching** — Markdown is rendered on every request. Fine for personal or low-traffic use; not suitable for high-traffic production serving.
160
+ - **No per-device session revocation** — rotating the pin invalidates all active sessions across all devices.
161
+ - **No plugin system** — the primary extension hook is `ld.flask_app`. Add routes and middleware directly on the Flask app.
162
+
163
+ ---
164
+
165
+ ## Deployment
166
+
167
+ > **Disclaimer:** leafdocs is a Flask application. Running it with `ld.run()` uses Flask's built-in development server, which is not suitable for production. For production use, you are responsible for:
168
+ > - Running behind a production WSGI server (Gunicorn recommended)
169
+ > - Terminating HTTPS at a reverse proxy (Nginx recommended)
170
+ > - Managing the process with a supervisor (systemd recommended)
171
+ >
172
+ > The instructions below cover a standard Nginx + Gunicorn + systemd setup.
173
+
174
+ ### AWS EC2 / GCP Compute Engine
175
+
176
+ The steps are identical for both — the only difference is how you provision the VM.
177
+
178
+ #### 1. Provision a VM
179
+
180
+ - **AWS:** Launch an EC2 instance (Ubuntu 24.04 LTS, `t3.micro` or larger). Open ports 80 and 443 in the security group.
181
+ - **GCP:** Create a Compute Engine instance (Ubuntu 24.04 LTS, `e2-micro` or larger). Open ports 80 and 443 in the firewall rules.
182
+
183
+ SSH into the instance.
184
+
185
+ #### 2. Install dependencies
186
+
187
+ ```bash
188
+ sudo apt update && sudo apt install -y python3-pip python3-venv nginx
189
+ ```
190
+
191
+ #### 3. Set up the app
192
+
193
+ ```bash
194
+ mkdir ~/leafdocs-app && cd ~/leafdocs-app
195
+ python3 -m venv .venv
196
+ source .venv/bin/activate
197
+ pip install leafdocs gunicorn
198
+ ```
199
+
200
+ Create your `docs/` directory and add your `.md` files:
201
+
202
+ ```bash
203
+ mkdir docs
204
+ ```
205
+
206
+ Create `main.py`:
207
+
208
+ ```python
209
+ from leafdocs import LeafDocs
210
+
211
+ ld = LeafDocs(docs_dir="./docs")
212
+ app = ld.flask_app
213
+ ```
214
+
215
+ Create `.env`:
216
+
217
+ ```
218
+ LEAFDOCS_PINS=yourpin
219
+ LEAFDOCS_SECRET_KEY=your-long-random-secret-here
220
+ ```
221
+
222
+ Test it runs:
223
+
224
+ ```bash
225
+ gunicorn --bind 127.0.0.1:8000 main:app
226
+ ```
227
+
228
+ #### 4. Configure systemd
229
+
230
+ Create `/etc/systemd/system/leafdocs.service`:
231
+
232
+ ```ini
233
+ [Unit]
234
+ Description=LeafDocs
235
+ After=network.target
236
+
237
+ [Service]
238
+ User=ubuntu
239
+ WorkingDirectory=/home/ubuntu/leafdocs-app
240
+ Environment="PATH=/home/ubuntu/leafdocs-app/.venv/bin"
241
+ ExecStart=/home/ubuntu/leafdocs-app/.venv/bin/gunicorn --workers 2 --bind 127.0.0.1:8000 main:app
242
+ Restart=always
243
+
244
+ [Install]
245
+ WantedBy=multi-user.target
246
+ ```
247
+
248
+ Enable and start:
249
+
250
+ ```bash
251
+ sudo systemctl daemon-reload
252
+ sudo systemctl enable leafdocs
253
+ sudo systemctl start leafdocs
254
+ sudo systemctl status leafdocs
255
+ ```
256
+
257
+ #### 5. Configure Nginx
258
+
259
+ Create `/etc/nginx/sites-available/leafdocs`:
260
+
261
+ ```nginx
262
+ server {
263
+ listen 80;
264
+ server_name your-domain.com;
265
+
266
+ location / {
267
+ proxy_pass http://127.0.0.1:8000;
268
+ proxy_set_header Host $host;
269
+ proxy_set_header X-Real-IP $remote_addr;
270
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
271
+ proxy_set_header X-Forwarded-Proto $scheme;
272
+ }
273
+ }
274
+ ```
275
+
276
+ Enable and reload:
277
+
278
+ ```bash
279
+ sudo ln -s /etc/nginx/sites-available/leafdocs /etc/nginx/sites-enabled/
280
+ sudo nginx -t
281
+ sudo systemctl reload nginx
282
+ ```
283
+
284
+ #### 6. Enable HTTPS
285
+
286
+ ```bash
287
+ sudo apt install -y certbot python3-certbot-nginx
288
+ sudo certbot --nginx -d your-domain.com
289
+ ```
290
+
291
+ Certbot will automatically update your Nginx config and set up auto-renewal.
292
+
293
+ ---
294
+
295
+ ## Development
296
+
297
+ ```bash
298
+ git clone https://github.com/anshulraj10/leafdocs
299
+ cd leafdocs
300
+ python -m venv .venv
301
+ source .venv/bin/activate
302
+ pip install -e ".[dev]"
303
+ pytest tests/ -v
304
+ ```
305
+
306
+ ---
307
+
308
+ ## License
309
+
310
+ MIT
@@ -0,0 +1,12 @@
1
+ leafdocs/__init__.py,sha256=aPLwjaEFi66zLwddwwC1F5NRpmhfWPjZIbO69gT-NjY,50
2
+ leafdocs/app.py,sha256=oCF0OjU5kk09-aqyBxlWAsmLDQax2miEa-n131fL5nw,5532
3
+ leafdocs/static/favicon.ico,sha256=P7a0vK6AUbGnm7-v7pkC2KCI2gNXYbTU9VTbkydb6Ww,15406
4
+ leafdocs/templates/base.html,sha256=JSpU2AUrajrfJ2HraNus3wCq4vri6QCV6OXZX45KzEg,2465
5
+ leafdocs/templates/index.html,sha256=k-XFcKPbg5CQVSsIC9O0Z86m-mPowL4P5aW1qBnO6JQ,3230
6
+ leafdocs/templates/login.html,sha256=h77Nb3yhPONQG-5IMxQnTkGA3JeneGc_4zLoscqfu8w,2641
7
+ leafdocs/templates/reader.html,sha256=EEdO2riFZND27wKqeFW5aE1YM3lw8BEwMLijo_8fgXM,3180
8
+ leafdocs-0.1.0.dist-info/licenses/LICENSE,sha256=8GgLd1xvknbViIubzI04ix_Ie0qe4KxaPIhZlNxHUJQ,1067
9
+ leafdocs-0.1.0.dist-info/METADATA,sha256=lHoftGAFVfD0h0aKfKInO_0y7DwIplg7G6LSE2TTdAo,7379
10
+ leafdocs-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ leafdocs-0.1.0.dist-info/top_level.txt,sha256=nCfUArZhW0Q9D7tBbxHpox1lMajha4M7TKPuj-grGs0,9
12
+ leafdocs-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anshul Raj
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ leafdocs