leafdocs 0.2.0__tar.gz → 0.3.0__tar.gz
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-0.2.0/src/leafdocs.egg-info → leafdocs-0.3.0}/PKG-INFO +67 -3
- {leafdocs-0.2.0 → leafdocs-0.3.0}/README.md +64 -2
- {leafdocs-0.2.0 → leafdocs-0.3.0}/pyproject.toml +3 -1
- leafdocs-0.3.0/src/leafdocs/app.py +347 -0
- leafdocs-0.3.0/src/leafdocs/static/reader-render.js +61 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/VERSIONS.md +71 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/LICENSE +21 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/contrib/auto-render.min.js +1 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/katex.min.css +1 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/katex/katex.min.js +1 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/mermaid/LICENSE +21 -0
- leafdocs-0.3.0/src/leafdocs/static/vendor/mermaid/mermaid.min.js +4328 -0
- {leafdocs-0.2.0 → leafdocs-0.3.0}/src/leafdocs/templates/index.html +83 -11
- {leafdocs-0.2.0 → leafdocs-0.3.0}/src/leafdocs/templates/reader.html +45 -5
- {leafdocs-0.2.0 → leafdocs-0.3.0/src/leafdocs.egg-info}/PKG-INFO +67 -3
- leafdocs-0.3.0/src/leafdocs.egg-info/SOURCES.txt +44 -0
- {leafdocs-0.2.0 → leafdocs-0.3.0}/src/leafdocs.egg-info/requires.txt +2 -0
- leafdocs-0.3.0/tests/test_app.py +819 -0
- leafdocs-0.2.0/src/leafdocs/app.py +0 -178
- leafdocs-0.2.0/src/leafdocs.egg-info/SOURCES.txt +0 -16
- leafdocs-0.2.0/tests/test_app.py +0 -211
- {leafdocs-0.2.0 → leafdocs-0.3.0}/LICENSE +0 -0
- {leafdocs-0.2.0 → leafdocs-0.3.0}/setup.cfg +0 -0
- {leafdocs-0.2.0 → leafdocs-0.3.0}/src/leafdocs/__init__.py +0 -0
- {leafdocs-0.2.0 → leafdocs-0.3.0}/src/leafdocs/static/favicon.ico +0 -0
- {leafdocs-0.2.0 → leafdocs-0.3.0}/src/leafdocs/templates/base.html +0 -0
- {leafdocs-0.2.0 → leafdocs-0.3.0}/src/leafdocs/templates/login.html +0 -0
- {leafdocs-0.2.0 → leafdocs-0.3.0}/src/leafdocs.egg-info/dependency_links.txt +0 -0
- {leafdocs-0.2.0 → leafdocs-0.3.0}/src/leafdocs.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: leafdocs
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Turn a directory of Markdown files into a self-hosted, searchable web reader.
|
|
5
5
|
Author-email: Anshul <you@example.com>
|
|
6
6
|
License: MIT
|
|
@@ -17,6 +17,8 @@ Requires-Dist: markdown>=3.5
|
|
|
17
17
|
Requires-Dist: python-frontmatter>=1.1
|
|
18
18
|
Requires-Dist: bcrypt>=4.1
|
|
19
19
|
Requires-Dist: python-dotenv>=1.0
|
|
20
|
+
Requires-Dist: nh3>=0.2.22
|
|
21
|
+
Requires-Dist: pymdown-extensions>=10.0
|
|
20
22
|
Provides-Extra: dev
|
|
21
23
|
Requires-Dist: pytest; extra == "dev"
|
|
22
24
|
Requires-Dist: pytest-flask; extra == "dev"
|
|
@@ -56,7 +58,7 @@ ld = LeafDocs(docs_dir="./docs")
|
|
|
56
58
|
ld.run()
|
|
57
59
|
```
|
|
58
60
|
|
|
59
|
-
Visit `http://127.0.0.1:5000`
|
|
61
|
+
Visit `http://127.0.0.1:5000` to see a searchable index of all `.md` files in your `docs/` directory, including subfolders.
|
|
60
62
|
|
|
61
63
|
### Constructor arguments
|
|
62
64
|
|
|
@@ -103,6 +105,56 @@ Frontmatter is optional — files without it are discovered and served normally.
|
|
|
103
105
|
|
|
104
106
|
---
|
|
105
107
|
|
|
108
|
+
## Folders and images
|
|
109
|
+
|
|
110
|
+
Subfolders under `docs_dir` are shown on the index as a collapsible tree, folders first, then docs. Folder names are titlecased the same way as filenames (`getting-started` becomes `Getting Started`). Folders with no `.md` files anywhere beneath them are left out. While the search box has text, matching docs are shown with their folders opened; clearing it restores the folders you had open.
|
|
111
|
+
|
|
112
|
+
A doc's URL is its path relative to `docs_dir` without `.md`, so `docs/guides/setup.md` is served at `/guides/setup`. Links ending in `.md`, such as `/guides/setup.md`, redirect to the same URL without it.
|
|
113
|
+
|
|
114
|
+
Skipped during discovery and returned as 404 by the reader:
|
|
115
|
+
|
|
116
|
+
- Any file or folder whose name starts with `.`, such as `.git/`
|
|
117
|
+
- Top-level `login.md`, `logout.md`, and anything under `static/`, since those paths belong to built-in routes. Nested files such as `guides/login.md` are fine.
|
|
118
|
+
|
|
119
|
+
Symlinks that point outside `docs_dir` are not listed and return 403.
|
|
120
|
+
|
|
121
|
+
Images (`.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`) inside `docs_dir` are served at their path, behind the same login as the docs, so a doc can reference an image next to it with a relative path:
|
|
122
|
+
|
|
123
|
+
```markdown
|
|
124
|
+

|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
No other file types are served. SVG is excluded because it can carry scripts.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Math and diagrams
|
|
132
|
+
|
|
133
|
+
Docs can contain TeX math and [mermaid](https://mermaid.js.org) diagrams. Both are rendered in the browser by copies of [KaTeX](https://katex.org) and mermaid bundled with the package, so nothing is fetched from a CDN. Pages without math or diagrams load neither library.
|
|
134
|
+
|
|
135
|
+
Inline math uses `\(...\)`. Single dollar signs are left alone so prices in prose render normally. Block math uses `$$...$$` or `\[...\]`:
|
|
136
|
+
|
|
137
|
+
```markdown
|
|
138
|
+
The area is \(\pi r^2\), and it costs $5.
|
|
139
|
+
|
|
140
|
+
$$
|
|
141
|
+
\int_0^1 x^2 \, dx = \frac{1}{3}
|
|
142
|
+
$$
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Diagrams are fenced code blocks tagged `mermaid`:
|
|
146
|
+
|
|
147
|
+
````markdown
|
|
148
|
+
```mermaid
|
|
149
|
+
flowchart LR
|
|
150
|
+
Write --> Commit --> Read
|
|
151
|
+
```
|
|
152
|
+
````
|
|
153
|
+
|
|
154
|
+
Mermaid runs with `securityLevel: "strict"`, so click handlers and raw HTML in diagram labels are disabled. Bundled versions and their licenses are listed in [`src/leafdocs/static/vendor/VERSIONS.md`](src/leafdocs/static/vendor/VERSIONS.md).
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
106
158
|
## Authentication
|
|
107
159
|
|
|
108
160
|
By default the server runs open with no login required.
|
|
@@ -117,7 +169,7 @@ Restart the server. All routes will now require a valid pin.
|
|
|
117
169
|
|
|
118
170
|
**How it works:**
|
|
119
171
|
- Pins are hashed with bcrypt at startup — raw values are never stored in memory
|
|
120
|
-
- A successful login issues
|
|
172
|
+
- A successful login issues a session cookie with `HttpOnly`, `SameSite=Lax`, and `Secure` (see `LEAFDOCS_COOKIE_SECURE` below)
|
|
121
173
|
- Multiple pins are supported — useful for sharing access without a shared secret
|
|
122
174
|
- To invalidate all sessions, rotate or remove the pin and restart
|
|
123
175
|
|
|
@@ -150,8 +202,17 @@ All pins and the secret key are configured via `.env` only. Copy `.env.example`
|
|
|
150
202
|
```
|
|
151
203
|
LEAFDOCS_PINS=pin1,pin2
|
|
152
204
|
LEAFDOCS_SECRET_KEY=your-secret-here
|
|
205
|
+
LEAFDOCS_COOKIE_SECURE=true
|
|
206
|
+
LEAFDOCS_ALLOW_HTML=false
|
|
153
207
|
```
|
|
154
208
|
|
|
209
|
+
| Variable | Default | Description |
|
|
210
|
+
|--------------------------|---------|-------------|
|
|
211
|
+
| `LEAFDOCS_PINS` | unset | Comma-separated pins. Unset or empty means no auth. |
|
|
212
|
+
| `LEAFDOCS_SECRET_KEY` | random | Session signing key. Random means sessions reset on restart. |
|
|
213
|
+
| `LEAFDOCS_COOKIE_SECURE` | `true` | Sets the `Secure` flag on the session cookie. `false`, `0`, `no`, or `off` disables it. Disable only when serving over plain HTTP, such as local or raw-IP testing; browsers will not send a `Secure` cookie over HTTP, so login loops back to the login page. |
|
|
214
|
+
| `LEAFDOCS_ALLOW_HTML` | `false` | Skips HTML sanitizing of rendered Markdown. `true`, `1`, `yes`, or `on` enables it. Only safe when you are the sole author of all content. |
|
|
215
|
+
|
|
155
216
|
---
|
|
156
217
|
|
|
157
218
|
## Known limitations
|
|
@@ -159,6 +220,7 @@ LEAFDOCS_SECRET_KEY=your-secret-here
|
|
|
159
220
|
- **No caching** — Markdown is rendered on every request. Fine for personal or low-traffic use; not suitable for high-traffic production serving.
|
|
160
221
|
- **No per-device session revocation** — rotating the pin invalidates all active sessions across all devices.
|
|
161
222
|
- **No plugin system** — the primary extension hook is `ld.flask_app`. Add routes and middleware directly on the Flask app.
|
|
223
|
+
- **Raw HTML in docs is sanitized** - rendered Markdown is passed through [nh3](https://github.com/messense/nh3) with an allowlist covering what the enabled extensions produce (headings with ids, lists, tables with alignment, code blocks with `language-*` classes, links, images, the `[TOC]` block, and `span` and `div` elements with class `arithmatex` for math). Scripts, event handler attributes, iframes, inline styles other than table alignment, and URL schemes other than `http`, `https`, and `mailto` are removed. Links get `rel="noopener noreferrer"`. Raw HTML outside the allowlist, such as `<details>`, is stripped, and `class` on `span` and `div` is removed unless it is exactly `arithmatex` (or `toc` on `div`). Set `LEAFDOCS_ALLOW_HTML=true` to turn sanitizing off; only do this when you are the sole author of all content.
|
|
162
224
|
|
|
163
225
|
---
|
|
164
226
|
|
|
@@ -190,6 +252,8 @@ pip install -e ".[dev]"
|
|
|
190
252
|
pytest tests/ -v
|
|
191
253
|
```
|
|
192
254
|
|
|
255
|
+
`python main.py` runs a dev server on http://127.0.0.1:5000 (cookie Secure flag is off for plain http).
|
|
256
|
+
|
|
193
257
|
---
|
|
194
258
|
|
|
195
259
|
## License
|
|
@@ -32,7 +32,7 @@ ld = LeafDocs(docs_dir="./docs")
|
|
|
32
32
|
ld.run()
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
Visit `http://127.0.0.1:5000`
|
|
35
|
+
Visit `http://127.0.0.1:5000` to see a searchable index of all `.md` files in your `docs/` directory, including subfolders.
|
|
36
36
|
|
|
37
37
|
### Constructor arguments
|
|
38
38
|
|
|
@@ -79,6 +79,56 @@ Frontmatter is optional — files without it are discovered and served normally.
|
|
|
79
79
|
|
|
80
80
|
---
|
|
81
81
|
|
|
82
|
+
## Folders and images
|
|
83
|
+
|
|
84
|
+
Subfolders under `docs_dir` are shown on the index as a collapsible tree, folders first, then docs. Folder names are titlecased the same way as filenames (`getting-started` becomes `Getting Started`). Folders with no `.md` files anywhere beneath them are left out. While the search box has text, matching docs are shown with their folders opened; clearing it restores the folders you had open.
|
|
85
|
+
|
|
86
|
+
A doc's URL is its path relative to `docs_dir` without `.md`, so `docs/guides/setup.md` is served at `/guides/setup`. Links ending in `.md`, such as `/guides/setup.md`, redirect to the same URL without it.
|
|
87
|
+
|
|
88
|
+
Skipped during discovery and returned as 404 by the reader:
|
|
89
|
+
|
|
90
|
+
- Any file or folder whose name starts with `.`, such as `.git/`
|
|
91
|
+
- Top-level `login.md`, `logout.md`, and anything under `static/`, since those paths belong to built-in routes. Nested files such as `guides/login.md` are fine.
|
|
92
|
+
|
|
93
|
+
Symlinks that point outside `docs_dir` are not listed and return 403.
|
|
94
|
+
|
|
95
|
+
Images (`.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`) inside `docs_dir` are served at their path, behind the same login as the docs, so a doc can reference an image next to it with a relative path:
|
|
96
|
+
|
|
97
|
+
```markdown
|
|
98
|
+

|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
No other file types are served. SVG is excluded because it can carry scripts.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Math and diagrams
|
|
106
|
+
|
|
107
|
+
Docs can contain TeX math and [mermaid](https://mermaid.js.org) diagrams. Both are rendered in the browser by copies of [KaTeX](https://katex.org) and mermaid bundled with the package, so nothing is fetched from a CDN. Pages without math or diagrams load neither library.
|
|
108
|
+
|
|
109
|
+
Inline math uses `\(...\)`. Single dollar signs are left alone so prices in prose render normally. Block math uses `$$...$$` or `\[...\]`:
|
|
110
|
+
|
|
111
|
+
```markdown
|
|
112
|
+
The area is \(\pi r^2\), and it costs $5.
|
|
113
|
+
|
|
114
|
+
$$
|
|
115
|
+
\int_0^1 x^2 \, dx = \frac{1}{3}
|
|
116
|
+
$$
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Diagrams are fenced code blocks tagged `mermaid`:
|
|
120
|
+
|
|
121
|
+
````markdown
|
|
122
|
+
```mermaid
|
|
123
|
+
flowchart LR
|
|
124
|
+
Write --> Commit --> Read
|
|
125
|
+
```
|
|
126
|
+
````
|
|
127
|
+
|
|
128
|
+
Mermaid runs with `securityLevel: "strict"`, so click handlers and raw HTML in diagram labels are disabled. Bundled versions and their licenses are listed in [`src/leafdocs/static/vendor/VERSIONS.md`](src/leafdocs/static/vendor/VERSIONS.md).
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
82
132
|
## Authentication
|
|
83
133
|
|
|
84
134
|
By default the server runs open with no login required.
|
|
@@ -93,7 +143,7 @@ Restart the server. All routes will now require a valid pin.
|
|
|
93
143
|
|
|
94
144
|
**How it works:**
|
|
95
145
|
- Pins are hashed with bcrypt at startup — raw values are never stored in memory
|
|
96
|
-
- A successful login issues
|
|
146
|
+
- A successful login issues a session cookie with `HttpOnly`, `SameSite=Lax`, and `Secure` (see `LEAFDOCS_COOKIE_SECURE` below)
|
|
97
147
|
- Multiple pins are supported — useful for sharing access without a shared secret
|
|
98
148
|
- To invalidate all sessions, rotate or remove the pin and restart
|
|
99
149
|
|
|
@@ -126,8 +176,17 @@ All pins and the secret key are configured via `.env` only. Copy `.env.example`
|
|
|
126
176
|
```
|
|
127
177
|
LEAFDOCS_PINS=pin1,pin2
|
|
128
178
|
LEAFDOCS_SECRET_KEY=your-secret-here
|
|
179
|
+
LEAFDOCS_COOKIE_SECURE=true
|
|
180
|
+
LEAFDOCS_ALLOW_HTML=false
|
|
129
181
|
```
|
|
130
182
|
|
|
183
|
+
| Variable | Default | Description |
|
|
184
|
+
|--------------------------|---------|-------------|
|
|
185
|
+
| `LEAFDOCS_PINS` | unset | Comma-separated pins. Unset or empty means no auth. |
|
|
186
|
+
| `LEAFDOCS_SECRET_KEY` | random | Session signing key. Random means sessions reset on restart. |
|
|
187
|
+
| `LEAFDOCS_COOKIE_SECURE` | `true` | Sets the `Secure` flag on the session cookie. `false`, `0`, `no`, or `off` disables it. Disable only when serving over plain HTTP, such as local or raw-IP testing; browsers will not send a `Secure` cookie over HTTP, so login loops back to the login page. |
|
|
188
|
+
| `LEAFDOCS_ALLOW_HTML` | `false` | Skips HTML sanitizing of rendered Markdown. `true`, `1`, `yes`, or `on` enables it. Only safe when you are the sole author of all content. |
|
|
189
|
+
|
|
131
190
|
---
|
|
132
191
|
|
|
133
192
|
## Known limitations
|
|
@@ -135,6 +194,7 @@ LEAFDOCS_SECRET_KEY=your-secret-here
|
|
|
135
194
|
- **No caching** — Markdown is rendered on every request. Fine for personal or low-traffic use; not suitable for high-traffic production serving.
|
|
136
195
|
- **No per-device session revocation** — rotating the pin invalidates all active sessions across all devices.
|
|
137
196
|
- **No plugin system** — the primary extension hook is `ld.flask_app`. Add routes and middleware directly on the Flask app.
|
|
197
|
+
- **Raw HTML in docs is sanitized** - rendered Markdown is passed through [nh3](https://github.com/messense/nh3) with an allowlist covering what the enabled extensions produce (headings with ids, lists, tables with alignment, code blocks with `language-*` classes, links, images, the `[TOC]` block, and `span` and `div` elements with class `arithmatex` for math). Scripts, event handler attributes, iframes, inline styles other than table alignment, and URL schemes other than `http`, `https`, and `mailto` are removed. Links get `rel="noopener noreferrer"`. Raw HTML outside the allowlist, such as `<details>`, is stripped, and `class` on `span` and `div` is removed unless it is exactly `arithmatex` (or `toc` on `div`). Set `LEAFDOCS_ALLOW_HTML=true` to turn sanitizing off; only do this when you are the sole author of all content.
|
|
138
198
|
|
|
139
199
|
---
|
|
140
200
|
|
|
@@ -166,6 +226,8 @@ pip install -e ".[dev]"
|
|
|
166
226
|
pytest tests/ -v
|
|
167
227
|
```
|
|
168
228
|
|
|
229
|
+
`python main.py` runs a dev server on http://127.0.0.1:5000 (cookie Secure flag is off for plain http).
|
|
230
|
+
|
|
169
231
|
---
|
|
170
232
|
|
|
171
233
|
## License
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "leafdocs"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Turn a directory of Markdown files into a self-hosted, searchable web reader."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -23,6 +23,8 @@ dependencies = [
|
|
|
23
23
|
"python-frontmatter>=1.1",
|
|
24
24
|
"bcrypt>=4.1",
|
|
25
25
|
"python-dotenv>=1.0",
|
|
26
|
+
"nh3>=0.2.22",
|
|
27
|
+
"pymdown-extensions>=10.0",
|
|
26
28
|
]
|
|
27
29
|
|
|
28
30
|
[project.optional-dependencies]
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pathlib
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
import secrets
|
|
6
|
+
|
|
7
|
+
import bcrypt
|
|
8
|
+
import frontmatter
|
|
9
|
+
import markdown
|
|
10
|
+
import nh3
|
|
11
|
+
from dotenv import load_dotenv
|
|
12
|
+
from flask import (
|
|
13
|
+
Flask,
|
|
14
|
+
abort,
|
|
15
|
+
redirect,
|
|
16
|
+
render_template,
|
|
17
|
+
request,
|
|
18
|
+
send_file,
|
|
19
|
+
session,
|
|
20
|
+
url_for,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
load_dotenv()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_pins() -> list[bytes]:
|
|
27
|
+
"""Read LEAFDOCS_PINS from env, hash each with bcrypt, return list of hashes."""
|
|
28
|
+
raw = os.getenv("LEAFDOCS_PINS", "").strip()
|
|
29
|
+
if not raw:
|
|
30
|
+
return []
|
|
31
|
+
pins = [p.strip() for p in raw.split(",") if p.strip()]
|
|
32
|
+
return [bcrypt.hashpw(p.encode(), bcrypt.gensalt()) for p in pins]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _verify_pin(candidate: str, hashes: list[bytes]) -> bool:
|
|
36
|
+
return any(bcrypt.checkpw(candidate.encode(), h) for h in hashes)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_MARKDOWN_EXTENSIONS = ["fenced_code", "tables", "toc", "nl2br", "pymdownx.arithmatex"]
|
|
40
|
+
# Generic mode emits plain span/div.arithmatex wrappers for a client-side renderer (KaTeX).
|
|
41
|
+
# Inline math is \(...\) only, so single dollar signs in prose (prices) stay plain text.
|
|
42
|
+
_MARKDOWN_EXTENSION_CONFIGS = {"pymdownx.arithmatex": {"generic": True, "inline_syntax": ["round"]}}
|
|
43
|
+
|
|
44
|
+
# Everything the enabled Markdown extensions (fenced_code, tables, toc, nl2br, arithmatex) emit
|
|
45
|
+
_ALLOWED_TAGS = {
|
|
46
|
+
"h1", "h2", "h3", "h4", "h5", "h6",
|
|
47
|
+
"p", "br", "hr", "ul", "ol", "li", "blockquote", "pre", "code",
|
|
48
|
+
"table", "thead", "tbody", "tr", "th", "td",
|
|
49
|
+
"em", "strong", "del", "a", "img", "div", "span",
|
|
50
|
+
}
|
|
51
|
+
_ALLOWED_ATTRIBUTES = {
|
|
52
|
+
**{h: {"id"} for h in ("h1", "h2", "h3", "h4", "h5", "h6")},
|
|
53
|
+
"a": {"href", "title"},
|
|
54
|
+
"img": {"src", "alt", "title"},
|
|
55
|
+
"code": {"class"},
|
|
56
|
+
"div": {"class"},
|
|
57
|
+
"span": {"class"},
|
|
58
|
+
"th": {"style"},
|
|
59
|
+
"td": {"style"},
|
|
60
|
+
}
|
|
61
|
+
_LANGUAGE_CLASS = re.compile(r"language-[\w+#.-]+")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _filter_attribute(tag: str, attr: str, value: str) -> str | None:
|
|
65
|
+
"""Restrict class values to what fenced_code (language-*), toc (toc) and arithmatex produce."""
|
|
66
|
+
if attr == "class":
|
|
67
|
+
if tag == "code" and _LANGUAGE_CLASS.fullmatch(value):
|
|
68
|
+
return value
|
|
69
|
+
if tag == "div" and value in ("toc", "arithmatex"):
|
|
70
|
+
return value
|
|
71
|
+
if tag == "span" and value == "arithmatex":
|
|
72
|
+
return value
|
|
73
|
+
return None
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _sanitize(html: str) -> str:
|
|
78
|
+
return nh3.clean(
|
|
79
|
+
html,
|
|
80
|
+
tags=_ALLOWED_TAGS,
|
|
81
|
+
attributes=_ALLOWED_ATTRIBUTES,
|
|
82
|
+
attribute_filter=_filter_attribute,
|
|
83
|
+
filter_style_properties={"text-align"},
|
|
84
|
+
url_schemes={"http", "https", "mailto"},
|
|
85
|
+
link_rel="noopener noreferrer",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
|
|
90
|
+
|
|
91
|
+
# Top-level slugs that would collide with built-in routes
|
|
92
|
+
_RESERVED_SLUGS = {"login", "logout"}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _slug(filepath: pathlib.Path, docs_dir: pathlib.Path) -> str:
|
|
96
|
+
"""Convert a .md filepath to its URL slug (posix path relative to docs_dir, no .md)."""
|
|
97
|
+
return filepath.relative_to(docs_dir).with_suffix("").as_posix()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _display_name(name: str) -> str:
|
|
101
|
+
"""Titlecase a file stem or directory name, treating - and _ as spaces."""
|
|
102
|
+
return name.replace("-", " ").replace("_", " ").title()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _doc_meta(post: frontmatter.Post, md_file: pathlib.Path) -> tuple[str, list[str]]:
|
|
106
|
+
"""Return (title, tags) from frontmatter, falling back to the filename for title."""
|
|
107
|
+
title = post.get("title") or _display_name(md_file.stem)
|
|
108
|
+
tags = post.get("tags") or []
|
|
109
|
+
if isinstance(tags, str):
|
|
110
|
+
tags = [t.strip() for t in tags.split(",")]
|
|
111
|
+
return title, tags
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _is_hidden(rel: pathlib.PurePath) -> bool:
|
|
115
|
+
return any(part.startswith(".") for part in rel.parts)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _is_reserved(slug: str) -> bool:
|
|
119
|
+
return slug in _RESERVED_SLUGS or slug.startswith("static/")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _is_inside(path: pathlib.Path, docs_dir: pathlib.Path) -> bool:
|
|
123
|
+
"""True if path, with symlinks resolved, is still inside docs_dir."""
|
|
124
|
+
try:
|
|
125
|
+
path.resolve().relative_to(docs_dir)
|
|
126
|
+
except ValueError:
|
|
127
|
+
return False
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _discover_docs(docs_dir: pathlib.Path) -> list[dict]:
|
|
132
|
+
"""
|
|
133
|
+
Recursively scan docs_dir for .md files. Return list of dicts with:
|
|
134
|
+
slug, title, tags, path
|
|
135
|
+
Sorted alphabetically by title. Hidden paths, symlinks that escape
|
|
136
|
+
docs_dir, and reserved slugs are skipped.
|
|
137
|
+
"""
|
|
138
|
+
docs = []
|
|
139
|
+
for md_file in sorted(docs_dir.rglob("*.md")):
|
|
140
|
+
if _is_hidden(md_file.relative_to(docs_dir)):
|
|
141
|
+
continue
|
|
142
|
+
if not md_file.is_file() or not _is_inside(md_file, docs_dir):
|
|
143
|
+
continue
|
|
144
|
+
slug = _slug(md_file, docs_dir)
|
|
145
|
+
if _is_reserved(slug):
|
|
146
|
+
continue
|
|
147
|
+
post = frontmatter.load(str(md_file))
|
|
148
|
+
title, tags = _doc_meta(post, md_file)
|
|
149
|
+
docs.append(
|
|
150
|
+
{
|
|
151
|
+
"slug": slug,
|
|
152
|
+
"title": title,
|
|
153
|
+
"tags": tags,
|
|
154
|
+
"path": md_file,
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
docs.sort(key=lambda d: d["title"].lower())
|
|
158
|
+
return docs
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _build_tree(docs: list[dict]) -> dict:
|
|
162
|
+
"""
|
|
163
|
+
Group docs into nested folders by slug. Each node has:
|
|
164
|
+
name, title, folders, docs
|
|
165
|
+
Folders come sorted by name and docs by title, both case-insensitive.
|
|
166
|
+
Only folders that hold docs somewhere beneath them exist.
|
|
167
|
+
"""
|
|
168
|
+
def new_node(name: str) -> dict:
|
|
169
|
+
return {"name": name, "title": _display_name(name), "folders": {}, "docs": []}
|
|
170
|
+
|
|
171
|
+
root = new_node("")
|
|
172
|
+
for doc in docs:
|
|
173
|
+
node = root
|
|
174
|
+
for part in doc["slug"].split("/")[:-1]:
|
|
175
|
+
node = node["folders"].setdefault(part, new_node(part))
|
|
176
|
+
node["docs"].append(doc)
|
|
177
|
+
|
|
178
|
+
def finish(node: dict) -> dict:
|
|
179
|
+
node["folders"] = [finish(f) for f in sorted(node["folders"].values(), key=lambda f: f["name"].lower())]
|
|
180
|
+
node["docs"].sort(key=lambda d: d["title"].lower())
|
|
181
|
+
return node
|
|
182
|
+
|
|
183
|
+
return finish(root)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class LeafDocs:
|
|
187
|
+
def __init__(
|
|
188
|
+
self,
|
|
189
|
+
docs_dir: str = "./docs",
|
|
190
|
+
secret_key: str | None = None,
|
|
191
|
+
):
|
|
192
|
+
self.docs_dir = pathlib.Path(docs_dir).resolve()
|
|
193
|
+
if not self.docs_dir.exists():
|
|
194
|
+
raise FileNotFoundError(f"docs_dir not found: {self.docs_dir}")
|
|
195
|
+
|
|
196
|
+
self._pin_hashes = _load_pins()
|
|
197
|
+
self._auth_enabled = bool(self._pin_hashes)
|
|
198
|
+
|
|
199
|
+
self.flask_app = Flask(__name__)
|
|
200
|
+
self.flask_app.secret_key = secret_key or os.getenv("LEAFDOCS_SECRET_KEY") or secrets.token_hex(32)
|
|
201
|
+
|
|
202
|
+
# Secure by default; only an explicit false-like value disables it (plain HTTP setups)
|
|
203
|
+
cookie_secure = os.getenv("LEAFDOCS_COOKIE_SECURE", "").strip().lower() not in ("false", "0", "no", "off")
|
|
204
|
+
self.flask_app.config.update(
|
|
205
|
+
SESSION_COOKIE_HTTPONLY=True,
|
|
206
|
+
SESSION_COOKIE_SECURE=cookie_secure,
|
|
207
|
+
SESSION_COOKIE_SAMESITE="Lax",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Off by default; raw HTML in docs is only safe when every author is trusted
|
|
211
|
+
self._allow_html = os.getenv("LEAFDOCS_ALLOW_HTML", "").strip().lower() in ("true", "1", "yes", "on")
|
|
212
|
+
|
|
213
|
+
self._register_routes()
|
|
214
|
+
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
# Internal helpers
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def _is_authenticated(self) -> bool:
|
|
220
|
+
if not self._auth_enabled:
|
|
221
|
+
return True
|
|
222
|
+
return session.get("authenticated") is True
|
|
223
|
+
|
|
224
|
+
def _require_auth(self):
|
|
225
|
+
if not self._is_authenticated():
|
|
226
|
+
return redirect(url_for("login"))
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
def _serve_image(self, slug: str):
|
|
230
|
+
"""Serve an image file from docs_dir. Caller has already checked auth."""
|
|
231
|
+
rel = pathlib.PurePosixPath(slug)
|
|
232
|
+
if _is_hidden(rel):
|
|
233
|
+
abort(404)
|
|
234
|
+
image = self.docs_dir / rel
|
|
235
|
+
if not image.is_file():
|
|
236
|
+
abort(404)
|
|
237
|
+
if not _is_inside(image, self.docs_dir):
|
|
238
|
+
abort(403)
|
|
239
|
+
return send_file(image.resolve())
|
|
240
|
+
|
|
241
|
+
# ------------------------------------------------------------------
|
|
242
|
+
# Route registration
|
|
243
|
+
# ------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
def _register_routes(self):
|
|
246
|
+
app = self.flask_app
|
|
247
|
+
|
|
248
|
+
# Attach self so inner functions can reference it
|
|
249
|
+
leafdocs = self
|
|
250
|
+
|
|
251
|
+
@app.route("/login", methods=["GET", "POST"])
|
|
252
|
+
def login():
|
|
253
|
+
if not leafdocs._auth_enabled:
|
|
254
|
+
return redirect(url_for("index"))
|
|
255
|
+
|
|
256
|
+
error = None
|
|
257
|
+
if request.method == "POST":
|
|
258
|
+
pin = request.form.get("pin", "")
|
|
259
|
+
if _verify_pin(pin, leafdocs._pin_hashes):
|
|
260
|
+
session["authenticated"] = True
|
|
261
|
+
session.permanent = False
|
|
262
|
+
return redirect(url_for("index"))
|
|
263
|
+
error = "Incorrect pin."
|
|
264
|
+
|
|
265
|
+
return render_template("login.html", error=error)
|
|
266
|
+
|
|
267
|
+
@app.route("/logout")
|
|
268
|
+
def logout():
|
|
269
|
+
session.clear()
|
|
270
|
+
return redirect(url_for("login"))
|
|
271
|
+
|
|
272
|
+
@app.route("/")
|
|
273
|
+
def index():
|
|
274
|
+
redir = leafdocs._require_auth()
|
|
275
|
+
if redir:
|
|
276
|
+
return redir
|
|
277
|
+
docs = _discover_docs(leafdocs.docs_dir)
|
|
278
|
+
return render_template(
|
|
279
|
+
"index.html",
|
|
280
|
+
docs=docs,
|
|
281
|
+
tree=_build_tree(docs),
|
|
282
|
+
auth_enabled=leafdocs._auth_enabled,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
@app.route("/<path:slug>")
|
|
286
|
+
def reader(slug: str):
|
|
287
|
+
redir = leafdocs._require_auth()
|
|
288
|
+
if redir:
|
|
289
|
+
return redir
|
|
290
|
+
|
|
291
|
+
# Old-style links to the .md file land on the doc itself
|
|
292
|
+
if slug.endswith(".md"):
|
|
293
|
+
if not slug[:-3]:
|
|
294
|
+
abort(404)
|
|
295
|
+
return redirect(url_for("reader", slug=slug[:-3]))
|
|
296
|
+
|
|
297
|
+
if pathlib.PurePosixPath(slug).suffix.lower() in _IMAGE_SUFFIXES:
|
|
298
|
+
return leafdocs._serve_image(slug)
|
|
299
|
+
|
|
300
|
+
rel = pathlib.PurePosixPath(f"{slug}.md")
|
|
301
|
+
if _is_hidden(rel) or _is_reserved(rel.as_posix()[:-3]):
|
|
302
|
+
abort(404)
|
|
303
|
+
|
|
304
|
+
# Find matching file
|
|
305
|
+
md_file = leafdocs.docs_dir / rel
|
|
306
|
+
if not md_file.is_file():
|
|
307
|
+
abort(404)
|
|
308
|
+
|
|
309
|
+
# Security: ensure resolved path is still inside docs_dir
|
|
310
|
+
if not _is_inside(md_file, leafdocs.docs_dir):
|
|
311
|
+
abort(403)
|
|
312
|
+
|
|
313
|
+
post = frontmatter.load(str(md_file))
|
|
314
|
+
title, tags = _doc_meta(post, md_file)
|
|
315
|
+
crumbs = [_display_name(part) for part in rel.parts[:-1]]
|
|
316
|
+
|
|
317
|
+
html_content = markdown.markdown(
|
|
318
|
+
post.content,
|
|
319
|
+
extensions=_MARKDOWN_EXTENSIONS,
|
|
320
|
+
extension_configs=_MARKDOWN_EXTENSION_CONFIGS,
|
|
321
|
+
)
|
|
322
|
+
if not leafdocs._allow_html:
|
|
323
|
+
html_content = _sanitize(html_content)
|
|
324
|
+
|
|
325
|
+
# Load the KaTeX and mermaid assets only on pages that need them. Match whole
|
|
326
|
+
# opening tags: text like `class="arithmatex"` in a code span keeps its quotes,
|
|
327
|
+
# but its < is always escaped, so it cannot match.
|
|
328
|
+
has_math = '<span class="arithmatex">' in html_content or '<div class="arithmatex">' in html_content
|
|
329
|
+
has_mermaid = '<code class="language-mermaid">' in html_content
|
|
330
|
+
|
|
331
|
+
return render_template(
|
|
332
|
+
"reader.html",
|
|
333
|
+
title=title,
|
|
334
|
+
tags=tags,
|
|
335
|
+
crumbs=crumbs,
|
|
336
|
+
content=html_content,
|
|
337
|
+
has_math=has_math,
|
|
338
|
+
has_mermaid=has_mermaid,
|
|
339
|
+
auth_enabled=leafdocs._auth_enabled,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# ------------------------------------------------------------------
|
|
343
|
+
# Public API
|
|
344
|
+
# ------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
def run(self, host: str = "127.0.0.1", port: int = 5000, **kwargs):
|
|
347
|
+
self.flask_app.run(host=host, port=port, **kwargs)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Client-side rendering for the reader view. reader.html loads the KaTeX and
|
|
2
|
+
// mermaid vendor scripts only on pages that need them, so each part below
|
|
3
|
+
// runs only when its library is present.
|
|
4
|
+
(function () {
|
|
5
|
+
// Math: arithmatex (generic mode) wraps TeX in \( \) or \[ \] inside
|
|
6
|
+
// .arithmatex elements. Render only there, never the whole page.
|
|
7
|
+
if (window.renderMathInElement) {
|
|
8
|
+
document.querySelectorAll('.arithmatex').forEach(function (el) {
|
|
9
|
+
window.renderMathInElement(el, {
|
|
10
|
+
delimiters: [
|
|
11
|
+
{ left: '\\(', right: '\\)', display: false },
|
|
12
|
+
{ left: '\\[', right: '\\]', display: true },
|
|
13
|
+
],
|
|
14
|
+
throwOnError: false,
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Diagrams: fenced_code emits <pre><code class="language-mermaid">.
|
|
20
|
+
// Copy the source as text (never as HTML) into the <pre class="mermaid">
|
|
21
|
+
// element mermaid expects.
|
|
22
|
+
if (window.mermaid) {
|
|
23
|
+
document.querySelectorAll('pre > code.language-mermaid').forEach(function (code) {
|
|
24
|
+
var pre = document.createElement('pre');
|
|
25
|
+
pre.className = 'mermaid';
|
|
26
|
+
pre.textContent = code.textContent;
|
|
27
|
+
code.parentElement.replaceWith(pre);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Theme colors come from the palette variables in base.html
|
|
31
|
+
var css = getComputedStyle(document.documentElement);
|
|
32
|
+
var color = function (name) { return css.getPropertyValue(name).trim(); };
|
|
33
|
+
|
|
34
|
+
window.mermaid.initialize({
|
|
35
|
+
startOnLoad: false,
|
|
36
|
+
securityLevel: 'strict',
|
|
37
|
+
theme: 'base',
|
|
38
|
+
themeVariables: {
|
|
39
|
+
darkMode: true,
|
|
40
|
+
background: color('--bg'),
|
|
41
|
+
fontFamily: color('--font-ui'),
|
|
42
|
+
textColor: color('--text'),
|
|
43
|
+
primaryColor: color('--surface'),
|
|
44
|
+
primaryTextColor: color('--text'),
|
|
45
|
+
primaryBorderColor: color('--accent'),
|
|
46
|
+
secondaryColor: color('--tag-bg'),
|
|
47
|
+
secondaryTextColor: color('--tag-text'),
|
|
48
|
+
secondaryBorderColor: color('--accent-dim'),
|
|
49
|
+
tertiaryColor: color('--bg'),
|
|
50
|
+
tertiaryTextColor: color('--text'),
|
|
51
|
+
tertiaryBorderColor: color('--border'),
|
|
52
|
+
lineColor: color('--muted'),
|
|
53
|
+
edgeLabelBackground: color('--surface'),
|
|
54
|
+
noteBkgColor: color('--surface'),
|
|
55
|
+
noteTextColor: color('--text'),
|
|
56
|
+
noteBorderColor: color('--border'),
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
window.mermaid.run({ querySelector: 'pre.mermaid' });
|
|
60
|
+
}
|
|
61
|
+
})();
|