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.
Files changed (49) hide show
  1. {leafdocs-0.2.0/src/leafdocs.egg-info → leafdocs-0.3.0}/PKG-INFO +67 -3
  2. {leafdocs-0.2.0 → leafdocs-0.3.0}/README.md +64 -2
  3. {leafdocs-0.2.0 → leafdocs-0.3.0}/pyproject.toml +3 -1
  4. leafdocs-0.3.0/src/leafdocs/app.py +347 -0
  5. leafdocs-0.3.0/src/leafdocs/static/reader-render.js +61 -0
  6. leafdocs-0.3.0/src/leafdocs/static/vendor/VERSIONS.md +71 -0
  7. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/LICENSE +21 -0
  8. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/contrib/auto-render.min.js +1 -0
  9. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  10. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  11. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  12. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  13. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  14. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  15. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  16. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  17. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  18. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  19. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  20. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  21. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  22. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  23. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  24. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  25. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  26. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  27. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  28. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  29. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/katex.min.css +1 -0
  30. leafdocs-0.3.0/src/leafdocs/static/vendor/katex/katex.min.js +1 -0
  31. leafdocs-0.3.0/src/leafdocs/static/vendor/mermaid/LICENSE +21 -0
  32. leafdocs-0.3.0/src/leafdocs/static/vendor/mermaid/mermaid.min.js +4328 -0
  33. {leafdocs-0.2.0 → leafdocs-0.3.0}/src/leafdocs/templates/index.html +83 -11
  34. {leafdocs-0.2.0 → leafdocs-0.3.0}/src/leafdocs/templates/reader.html +45 -5
  35. {leafdocs-0.2.0 → leafdocs-0.3.0/src/leafdocs.egg-info}/PKG-INFO +67 -3
  36. leafdocs-0.3.0/src/leafdocs.egg-info/SOURCES.txt +44 -0
  37. {leafdocs-0.2.0 → leafdocs-0.3.0}/src/leafdocs.egg-info/requires.txt +2 -0
  38. leafdocs-0.3.0/tests/test_app.py +819 -0
  39. leafdocs-0.2.0/src/leafdocs/app.py +0 -178
  40. leafdocs-0.2.0/src/leafdocs.egg-info/SOURCES.txt +0 -16
  41. leafdocs-0.2.0/tests/test_app.py +0 -211
  42. {leafdocs-0.2.0 → leafdocs-0.3.0}/LICENSE +0 -0
  43. {leafdocs-0.2.0 → leafdocs-0.3.0}/setup.cfg +0 -0
  44. {leafdocs-0.2.0 → leafdocs-0.3.0}/src/leafdocs/__init__.py +0 -0
  45. {leafdocs-0.2.0 → leafdocs-0.3.0}/src/leafdocs/static/favicon.ico +0 -0
  46. {leafdocs-0.2.0 → leafdocs-0.3.0}/src/leafdocs/templates/base.html +0 -0
  47. {leafdocs-0.2.0 → leafdocs-0.3.0}/src/leafdocs/templates/login.html +0 -0
  48. {leafdocs-0.2.0 → leafdocs-0.3.0}/src/leafdocs.egg-info/dependency_links.txt +0 -0
  49. {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.2.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` — you'll see a searchable index of all `.md` files in your `docs/` directory.
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
+ ![Diagram](diagram.png)
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 an `httponly` session cookie
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` — you'll see a searchable index of all `.md` files in your `docs/` directory.
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
+ ![Diagram](diagram.png)
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 an `httponly` session cookie
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.2.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
+ })();