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 +3 -0
- leafdocs/app.py +178 -0
- leafdocs/static/favicon.ico +0 -0
- leafdocs/templates/base.html +94 -0
- leafdocs/templates/index.html +141 -0
- leafdocs/templates/login.html +108 -0
- leafdocs/templates/reader.html +145 -0
- leafdocs-0.1.0.dist-info/METADATA +310 -0
- leafdocs-0.1.0.dist-info/RECORD +12 -0
- leafdocs-0.1.0.dist-info/WHEEL +5 -0
- leafdocs-0.1.0.dist-info/licenses/LICENSE +21 -0
- leafdocs-0.1.0.dist-info/top_level.txt +1 -0
leafdocs/__init__.py
ADDED
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,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
|