django-md-docs 0.1.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.
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.3
2
+ Name: django-md-docs
3
+ Version: 0.1.0
4
+ Summary: A reusable Django app that serves a markdown folder as a documentation site at /docs
5
+ Author: Gabriel Chaves
6
+ Author-email: Gabriel Chaves <gabriel.chaves@olist.com>
7
+ Requires-Dist: django>=5.0
8
+ Requires-Dist: markdown>=3.5
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+
12
+ # django-md-docs
13
+
14
+ A reusable Django app that renders a folder of Markdown files as a documentation site at `/docs`.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ pip install django-md-docs
20
+ ```
21
+
22
+ ## Setup
23
+
24
+ **1. `settings.py`**
25
+
26
+ ```python
27
+ INSTALLED_APPS = [
28
+ ...
29
+ "md_docs",
30
+ ]
31
+
32
+ # Optional settings (all have defaults):
33
+ MD_DOCS_DIR = BASE_DIR / "md-docs" # default: BASE_DIR / "md-docs"
34
+ MD_DOCS_LOGIN_REQUIRED = True # default: True
35
+ MD_DOCS_BRAND = "My Project" # default: "Documentation"
36
+ MD_DOCS_LOGOUT_URL = "/accounts/logout/" # default: None (hides the logout button)
37
+ ```
38
+
39
+ **2. `urls.py`**
40
+
41
+ ```python
42
+ from django.urls import include, path
43
+
44
+ urlpatterns = [
45
+ ...
46
+ path("docs/", include("md_docs.urls")),
47
+ ]
48
+ ```
49
+
50
+ **3. Create your markdown files**
51
+
52
+ ```
53
+ md-docs/
54
+ ├── index.md → /docs/
55
+ ├── guide/
56
+ │ ├── index.md → /docs/guide/
57
+ │ └── setup.md → /docs/guide/setup
58
+ └── api/
59
+ ├── index.md → /docs/api/
60
+ └── endpoints.md → /docs/api/endpoints
61
+ ```
62
+
63
+ The navigation sidebar is built automatically from the directory structure. The first heading in each file is used as its nav label.
64
+
65
+ ## Settings reference
66
+
67
+ | Setting | Default | Description |
68
+ |---|---|---|
69
+ | `MD_DOCS_DIR` | `BASE_DIR / "md-docs"` | Path to the folder containing `.md` files |
70
+ | `MD_DOCS_LOGIN_REQUIRED` | `True` | Redirect unauthenticated users to `LOGIN_URL` |
71
+ | `MD_DOCS_BRAND` | `"Documentation"` | Brand name displayed in the sidebar header |
72
+ | `MD_DOCS_LOGOUT_URL` | `None` | URL for the logout POST action. When `None`, the logout button is hidden |
73
+
74
+ ## Supported Markdown extensions
75
+
76
+ - `tables`
77
+ - `fenced_code`
78
+ - `toc` (table of contents, shown in the right sidebar)
79
+ - `attr_list`
@@ -0,0 +1,68 @@
1
+ # django-md-docs
2
+
3
+ A reusable Django app that renders a folder of Markdown files as a documentation site at `/docs`.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install django-md-docs
9
+ ```
10
+
11
+ ## Setup
12
+
13
+ **1. `settings.py`**
14
+
15
+ ```python
16
+ INSTALLED_APPS = [
17
+ ...
18
+ "md_docs",
19
+ ]
20
+
21
+ # Optional settings (all have defaults):
22
+ MD_DOCS_DIR = BASE_DIR / "md-docs" # default: BASE_DIR / "md-docs"
23
+ MD_DOCS_LOGIN_REQUIRED = True # default: True
24
+ MD_DOCS_BRAND = "My Project" # default: "Documentation"
25
+ MD_DOCS_LOGOUT_URL = "/accounts/logout/" # default: None (hides the logout button)
26
+ ```
27
+
28
+ **2. `urls.py`**
29
+
30
+ ```python
31
+ from django.urls import include, path
32
+
33
+ urlpatterns = [
34
+ ...
35
+ path("docs/", include("md_docs.urls")),
36
+ ]
37
+ ```
38
+
39
+ **3. Create your markdown files**
40
+
41
+ ```
42
+ md-docs/
43
+ ├── index.md → /docs/
44
+ ├── guide/
45
+ │ ├── index.md → /docs/guide/
46
+ │ └── setup.md → /docs/guide/setup
47
+ └── api/
48
+ ├── index.md → /docs/api/
49
+ └── endpoints.md → /docs/api/endpoints
50
+ ```
51
+
52
+ The navigation sidebar is built automatically from the directory structure. The first heading in each file is used as its nav label.
53
+
54
+ ## Settings reference
55
+
56
+ | Setting | Default | Description |
57
+ |---|---|---|
58
+ | `MD_DOCS_DIR` | `BASE_DIR / "md-docs"` | Path to the folder containing `.md` files |
59
+ | `MD_DOCS_LOGIN_REQUIRED` | `True` | Redirect unauthenticated users to `LOGIN_URL` |
60
+ | `MD_DOCS_BRAND` | `"Documentation"` | Brand name displayed in the sidebar header |
61
+ | `MD_DOCS_LOGOUT_URL` | `None` | URL for the logout POST action. When `None`, the logout button is hidden |
62
+
63
+ ## Supported Markdown extensions
64
+
65
+ - `tables`
66
+ - `fenced_code`
67
+ - `toc` (table of contents, shown in the right sidebar)
68
+ - `attr_list`
@@ -0,0 +1,83 @@
1
+ [project]
2
+ name = "django-md-docs"
3
+ version = "0.1.0"
4
+ description = "A reusable Django app that serves a markdown folder as a documentation site at /docs"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Gabriel Chaves", email = "gabriel.chaves@olist.com" }
8
+ ]
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "django>=5.0",
12
+ "markdown>=3.5",
13
+ ]
14
+
15
+ [dependency-groups]
16
+ dev = [
17
+ "django-stubs>=5.2.9",
18
+ "mypy>=1.19.1",
19
+ "pre-commit>=4.0.0",
20
+ "pytest>=9.0.2",
21
+ "pytest-asyncio>=1.3.0",
22
+ "pytest-cov>=7.0.0",
23
+ "pytest-django>=4.12.0",
24
+ "types-markdown>=3.10.2.20260211",
25
+ ]
26
+
27
+ [build-system]
28
+ requires = ["uv_build>=0.10.2,<0.11.0"]
29
+ build-backend = "uv_build"
30
+
31
+ [tool.uv.build-backend]
32
+ module-name = "md_docs"
33
+
34
+ [tool.mypy]
35
+ plugins = ["mypy_django_plugin.main"]
36
+ strict = true
37
+
38
+ [tool.django-stubs]
39
+ django_settings_module = "django.conf.global_settings"
40
+
41
+ [tool.pytest.ini_options]
42
+ DJANGO_SETTINGS_MODULE = "tests.settings"
43
+ asyncio_mode = "auto"
44
+ pythonpath = [".", "test-app"]
45
+ addopts = "--cov --cov-report=term-missing"
46
+
47
+ [tool.ruff]
48
+ target-version = "py311"
49
+ line-length = 110
50
+
51
+ [tool.ruff.lint]
52
+ select = ["ALL"]
53
+ ignore = [
54
+ # annotations — mypy strict mode already covers this
55
+ "ANN",
56
+ # docstrings — not enforced in this project
57
+ "D100", "D107",
58
+ # pydoclint — redundant with mypy
59
+ "DOC",
60
+ # pandas / numpy / airflow / fastapi — not used
61
+ "PD", "NPY", "AIR", "FAST",
62
+ # conflicts with ruff formatter
63
+ "COM812", "ISC001"
64
+ ]
65
+
66
+ [tool.ruff.lint.per-file-ignores]
67
+ "tests/**" = [
68
+ "S101", # assert allowed in tests
69
+ "ARG", # unused arguments (fixtures)
70
+ "SLF001", # private member access in tests
71
+ "PLR2004", # magic values in tests
72
+ ]
73
+ "test-app/**" = ["INP001"]
74
+
75
+ [tool.ruff.lint.isort]
76
+ known-first-party = ["md_docs"]
77
+
78
+ [tool.ruff.lint.pylint]
79
+ max-args = 8
80
+
81
+ [tool.coverage.run]
82
+ source = ["src/md_docs", "test-app"]
83
+ omit = ["tests/*", "test-app/manage.py", "test-app/test_app/settings.py"]
@@ -0,0 +1 @@
1
+ """Django app that serves a Markdown directory as a documentation site."""
@@ -0,0 +1,8 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class MdDocsConfig(AppConfig):
5
+ """Django app config for md_docs."""
6
+
7
+ name = "md_docs"
8
+ verbose_name = "MD Docs"
@@ -0,0 +1,310 @@
1
+ /* Color palette mirroring Django admin (django/contrib/admin/static/admin/css/base.css) */
2
+ :root {
3
+ --primary: #79aec8;
4
+ --secondary: #417690;
5
+ --primary-fg: #fff;
6
+
7
+ --body-fg: #333;
8
+ --body-bg: #fff;
9
+ --body-quiet-color: #666;
10
+ --body-loud-color: #000;
11
+
12
+ --link-fg: #417893;
13
+ --link-hover-color: #036;
14
+
15
+ --hairline-color: #e8e8e8;
16
+ --border-color: #ccc;
17
+
18
+ --error-fg: #ba2121;
19
+
20
+ --darkened-bg: #f8f8f8;
21
+
22
+ --default-button-bg: #205067;
23
+
24
+ --breadcrumbs-fg: #c4dce8;
25
+
26
+ --font-family-primary:
27
+ "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial, sans-serif;
28
+
29
+ --font-family-monospace:
30
+ ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono",
31
+ "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro",
32
+ "Fira Mono", "Droid Sans Mono", "Courier New", monospace;
33
+ }
34
+
35
+ *,
36
+ *::before,
37
+ *::after {
38
+ box-sizing: border-box;
39
+ margin: 0;
40
+ padding: 0;
41
+ }
42
+
43
+ body {
44
+ display: flex;
45
+ min-height: 100vh;
46
+ background: var(--darkened-bg);
47
+ color: var(--body-fg);
48
+ font-family: var(--font-family-primary);
49
+ font-size: 15px;
50
+ line-height: 1.6;
51
+ }
52
+
53
+ /* ── Sidebar nav ─────────────────────────────────────────────── */
54
+ nav {
55
+ width: 240px;
56
+ min-width: 240px;
57
+ background: var(--secondary);
58
+ padding: 24px 0;
59
+ position: sticky;
60
+ top: 0;
61
+ height: 100vh;
62
+ overflow-y: auto;
63
+ display: flex;
64
+ flex-direction: column;
65
+ }
66
+
67
+ nav .nav-links {
68
+ flex: 1;
69
+ }
70
+
71
+ nav .nav-footer {
72
+ padding: 16px 20px;
73
+ border-top: 1px solid rgba(255, 255, 255, 0.15);
74
+ }
75
+
76
+ nav .nav-footer form {
77
+ margin: 0;
78
+ }
79
+
80
+ nav .nav-footer button {
81
+ width: 100%;
82
+ padding: 7px 12px;
83
+ font-size: 13px;
84
+ font-family: inherit;
85
+ font-weight: 500;
86
+ color: rgba(255, 255, 255, 0.8);
87
+ background: transparent;
88
+ border: 1px solid rgba(255, 255, 255, 0.25);
89
+ border-radius: 4px;
90
+ cursor: pointer;
91
+ text-align: left;
92
+ }
93
+
94
+ nav .nav-footer button:hover {
95
+ color: var(--primary-fg);
96
+ background: rgba(255, 255, 255, 0.12);
97
+ }
98
+
99
+ nav .nav-brand {
100
+ display: block;
101
+ padding: 0 20px 20px;
102
+ font-weight: 700;
103
+ font-size: 14px;
104
+ color: var(--primary-fg);
105
+ text-decoration: none;
106
+ border-bottom: 1px solid rgba(255, 255, 255, 0.15);
107
+ border-left: none;
108
+ margin-bottom: 12px;
109
+ letter-spacing: 0.02em;
110
+ }
111
+
112
+ nav .nav-brand:hover {
113
+ background: none;
114
+ color: var(--primary-fg);
115
+ }
116
+
117
+ nav a {
118
+ display: block;
119
+ padding: 5px 20px;
120
+ font-size: 13.5px;
121
+ color: rgba(255, 255, 255, 0.75);
122
+ text-decoration: none;
123
+ border-left: 3px solid transparent;
124
+ }
125
+
126
+ nav a:hover {
127
+ background: rgba(255, 255, 255, 0.1);
128
+ color: var(--primary-fg);
129
+ }
130
+
131
+ nav a.active {
132
+ font-weight: 600;
133
+ color: var(--primary-fg);
134
+ border-left-color: var(--primary);
135
+ background: rgba(121, 174, 200, 0.2);
136
+ }
137
+
138
+ /* ── Main area ───────────────────────────────────────────────── */
139
+ .layout {
140
+ flex: 1;
141
+ display: flex;
142
+ overflow: hidden;
143
+ }
144
+
145
+ main {
146
+ flex: 1;
147
+ padding: 40px 48px;
148
+ max-width: 860px;
149
+ overflow-y: auto;
150
+ background: var(--body-bg);
151
+ }
152
+
153
+ /* ── TOC sidebar ─────────────────────────────────────────────── */
154
+ .toc-sidebar {
155
+ width: 200px;
156
+ min-width: 200px;
157
+ padding: 40px 16px;
158
+ position: sticky;
159
+ top: 0;
160
+ height: 100vh;
161
+ overflow-y: auto;
162
+ font-size: 12.5px;
163
+ background: var(--darkened-bg);
164
+ }
165
+
166
+ .toc-sidebar .toc-label {
167
+ font-weight: 600;
168
+ font-size: 11px;
169
+ text-transform: uppercase;
170
+ letter-spacing: 0.06em;
171
+ color: var(--body-quiet-color);
172
+ margin-bottom: 8px;
173
+ }
174
+
175
+ .toc-sidebar .toc ul {
176
+ list-style: none;
177
+ }
178
+
179
+ .toc-sidebar .toc li {
180
+ margin: 4px 0;
181
+ }
182
+
183
+ .toc-sidebar .toc a {
184
+ color: var(--link-fg);
185
+ text-decoration: none;
186
+ }
187
+
188
+ .toc-sidebar .toc a:hover {
189
+ color: var(--link-hover-color);
190
+ }
191
+
192
+ .toc-sidebar .toc ul ul {
193
+ padding-left: 12px;
194
+ }
195
+
196
+ /* ── Markdown content ────────────────────────────────────────── */
197
+ .md h1,
198
+ .md h2,
199
+ .md h3,
200
+ .md h4 {
201
+ font-weight: 600;
202
+ line-height: 1.25;
203
+ margin-top: 24px;
204
+ margin-bottom: 12px;
205
+ color: var(--body-loud-color);
206
+ }
207
+
208
+ .md h1 {
209
+ font-size: 2em;
210
+ border-bottom: 2px solid var(--hairline-color);
211
+ padding-bottom: 8px;
212
+ margin-top: 0;
213
+ }
214
+
215
+ .md h2 {
216
+ font-size: 1.4em;
217
+ border-bottom: 1px solid var(--hairline-color);
218
+ padding-bottom: 6px;
219
+ }
220
+
221
+ .md h3 { font-size: 1.15em; }
222
+ .md h4 { font-size: 1em; }
223
+
224
+ .md p {
225
+ margin-bottom: 16px;
226
+ }
227
+
228
+ .md a {
229
+ color: var(--link-fg);
230
+ text-decoration: none;
231
+ }
232
+
233
+ .md a:hover {
234
+ text-decoration: underline;
235
+ }
236
+
237
+ .md code {
238
+ background: var(--darkened-bg);
239
+ border: 1px solid var(--border-color);
240
+ border-radius: 4px;
241
+ padding: 2px 5px;
242
+ font-family: var(--font-family-monospace);
243
+ font-size: 87%;
244
+ color: var(--error-fg);
245
+ }
246
+
247
+ .md pre {
248
+ background: var(--default-button-bg);
249
+ border: none;
250
+ border-radius: 6px;
251
+ padding: 16px;
252
+ overflow-x: auto;
253
+ margin-bottom: 16px;
254
+ }
255
+
256
+ .md pre code {
257
+ background: none;
258
+ border: none;
259
+ padding: 0;
260
+ font-size: 13px;
261
+ color: var(--breadcrumbs-fg);
262
+ }
263
+
264
+ .md table {
265
+ border-collapse: collapse;
266
+ width: 100%;
267
+ margin-bottom: 16px;
268
+ font-size: 14px;
269
+ }
270
+
271
+ .md th,
272
+ .md td {
273
+ border: 1px solid var(--border-color);
274
+ padding: 8px 12px;
275
+ text-align: left;
276
+ }
277
+
278
+ .md th {
279
+ background: var(--secondary);
280
+ color: var(--primary-fg);
281
+ font-weight: 600;
282
+ }
283
+
284
+ .md tr:nth-child(even) td {
285
+ background: var(--darkened-bg);
286
+ }
287
+
288
+ .md ul,
289
+ .md ol {
290
+ padding-left: 24px;
291
+ margin-bottom: 16px;
292
+ }
293
+
294
+ .md li {
295
+ margin: 4px 0;
296
+ }
297
+
298
+ .md blockquote {
299
+ border-left: 4px solid var(--primary);
300
+ padding: 8px 16px;
301
+ color: var(--body-quiet-color);
302
+ margin-bottom: 16px;
303
+ background: var(--darkened-bg);
304
+ }
305
+
306
+ .md hr {
307
+ border: none;
308
+ border-top: 2px solid var(--hairline-color);
309
+ margin: 24px 0;
310
+ }
@@ -0,0 +1,41 @@
1
+ {% load static %}
2
+ <!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>{{ brand }}</title>
8
+ <link rel="stylesheet" href="{% static 'md_docs/css/page.css' %}" />
9
+ </head>
10
+
11
+ <body>
12
+ <nav>
13
+ <div class="nav-links">
14
+ <a href="{% url 'md_docs:docs-index' %}" class="nav-brand">{{ brand }}</a>
15
+ {% for item in nav %}
16
+ <a href="{{ item.url }}" style="padding-left: {{ item.padding_left }}px" class="{% if item.url == current_url %}active{% endif %}">{{ item.title }}</a>
17
+ {% endfor %}
18
+ </div>
19
+ {% if logout_url %}
20
+ <div class="nav-footer">
21
+ <form method="post" action="{{ logout_url }}">
22
+ {% csrf_token %}
23
+ <input type="hidden" name="next" value="{{ current_url }}" />
24
+ <button type="submit">Logout</button>
25
+ </form>
26
+ </div>
27
+ {% endif %}
28
+ </nav>
29
+ <div class="layout">
30
+ <main>
31
+ <div class="md">{{ body|safe }}</div>
32
+ </main>
33
+ {% if toc %}
34
+ <aside class="toc-sidebar">
35
+ <div class="toc-label">On this page</div>
36
+ <div class="toc">{{ toc|safe }}</div>
37
+ </aside>
38
+ {% endif %}
39
+ </div>
40
+ </body>
41
+ </html>
@@ -0,0 +1,10 @@
1
+ from django.urls import path
2
+
3
+ from . import views
4
+
5
+ app_name = "md_docs"
6
+
7
+ urlpatterns = [
8
+ path("", views.docs_page, name="docs-index"),
9
+ path("<path:page>", views.docs_page, name="docs-page"),
10
+ ]
@@ -0,0 +1,133 @@
1
+ from pathlib import Path
2
+ from typing import Any
3
+
4
+ import markdown
5
+ from django.conf import settings
6
+ from django.contrib.auth.views import redirect_to_login
7
+ from django.core.exceptions import ImproperlyConfigured
8
+ from django.http import (
9
+ Http404,
10
+ HttpRequest,
11
+ HttpResponse,
12
+ HttpResponsePermanentRedirect,
13
+ )
14
+ from django.shortcuts import render
15
+ from django.urls import reverse
16
+
17
+
18
+ def _get_content_dir() -> Path:
19
+ path = getattr(settings, "MD_DOCS_DIR", None)
20
+ if path is None:
21
+ base_dir = getattr(settings, "BASE_DIR", None)
22
+ if base_dir is None:
23
+ msg = "Set MD_DOCS_DIR or BASE_DIR in settings."
24
+ raise ImproperlyConfigured(msg)
25
+ path = Path(base_dir) / "md-docs"
26
+ return Path(path).resolve()
27
+
28
+
29
+ def _resolve_md_file(content_dir: Path, page: str) -> Path:
30
+ """Translate a URL slug to an absolute markdown file path.
31
+
32
+ Raises Http404 if the resolved path escapes the content directory.
33
+ """
34
+ clean = page.strip("/")
35
+
36
+ if clean == "":
37
+ candidate = content_dir / "index.md"
38
+ else:
39
+ direct = (content_dir / clean).with_suffix(".md")
40
+ candidate = direct if direct.exists() else content_dir / clean / "index.md"
41
+
42
+ resolved = candidate.resolve()
43
+ if not resolved.is_relative_to(content_dir):
44
+ raise Http404
45
+
46
+ return resolved
47
+
48
+
49
+ def _build_nav(content_dir: Path) -> list[dict[str, Any]]:
50
+ """Walk the content directory and return an ordered nav list.
51
+
52
+ Each entry has:
53
+ url - absolute URL to the page
54
+ title - first heading found in the file (fallback: filename stem)
55
+ depth - nesting level (0 = root, 1 = section, ...)
56
+ """
57
+ nav = []
58
+
59
+ def _sort_key(p: Path) -> tuple[str, ...]:
60
+ parts = list(p.relative_to(content_dir).parts)
61
+ if parts[-1] == "index.md":
62
+ parts[-1] = ""
63
+ return tuple(parts)
64
+
65
+ for path in sorted(content_dir.rglob("*.md"), key=_sort_key):
66
+ rel = path.relative_to(content_dir)
67
+ parts = rel.parts
68
+
69
+ is_index = parts[-1] == "index.md"
70
+ if parts == ("index.md",):
71
+ continue
72
+
73
+ slug = "/".join(parts[:-1]) if is_index else "/".join(parts).removesuffix(".md")
74
+ url = reverse("md_docs:docs-page", args=[slug]) if slug else reverse("md_docs:docs-index")
75
+
76
+ first_line = next((line for line in path.read_text().splitlines() if line.strip()), "")
77
+ title = first_line.lstrip("#").strip() or path.stem
78
+
79
+ depth = max(0, len(parts) - 2) if is_index else len(parts) - 1
80
+ nav.append({"url": url, "title": title, "padding_left": 20 + depth * 20})
81
+
82
+ return nav
83
+
84
+
85
+ async def docs_page(request: HttpRequest, page: str = "") -> HttpResponse:
86
+ """Serve a rendered markdown documentation page.
87
+
88
+ The ``page`` slug maps to a file under the configured ``MD_DOCS_DIR``:
89
+ - empty / ``index`` → ``<MD_DOCS_DIR>/index.md``
90
+ - ``api/messages`` → ``<MD_DOCS_DIR>/api/messages.md``
91
+ - ``admin/`` → ``<MD_DOCS_DIR>/admin/index.md``
92
+
93
+ Settings
94
+ --------
95
+ MD_DOCS_DIR Path to the directory containing .md files.
96
+ Defaults to BASE_DIR / "md-docs".
97
+ MD_DOCS_LOGIN_REQUIRED Restrict access to authenticated users (default True).
98
+ MD_DOCS_BRAND Brand name shown in the sidebar (default "Documentation").
99
+ MD_DOCS_LOGOUT_URL URL for the logout action. When None the logout button
100
+ is hidden (default None).
101
+ """
102
+ if getattr(settings, "MD_DOCS_LOGIN_REQUIRED", True) and not (await request.auser()).is_authenticated:
103
+ return redirect_to_login(request.get_full_path())
104
+
105
+ content_dir = _get_content_dir()
106
+ md_file = _resolve_md_file(content_dir, page)
107
+ if not md_file.exists():
108
+ raise Http404
109
+
110
+ if md_file.name == "index.md" and page and not page.endswith("/"):
111
+ return HttpResponsePermanentRedirect(reverse("md_docs:docs-page", args=[page + "/"]))
112
+
113
+ md = markdown.Markdown(
114
+ extensions=["tables", "fenced_code", "toc", "attr_list"],
115
+ extension_configs={"toc": {"title": "Contents"}},
116
+ )
117
+ body = md.convert(md_file.read_text())
118
+
119
+ slug = page.strip("/")
120
+ current_url = reverse("md_docs:docs-page", args=[slug]) if slug else reverse("md_docs:docs-index")
121
+
122
+ return render(
123
+ request,
124
+ "md_docs/page.html",
125
+ {
126
+ "body": body,
127
+ "toc": getattr(md, "toc", ""),
128
+ "nav": _build_nav(content_dir),
129
+ "current_url": current_url,
130
+ "brand": getattr(settings, "MD_DOCS_BRAND", "Documentation"),
131
+ "logout_url": getattr(settings, "MD_DOCS_LOGOUT_URL", None),
132
+ },
133
+ )