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.
- django_md_docs-0.1.0/PKG-INFO +79 -0
- django_md_docs-0.1.0/README.md +68 -0
- django_md_docs-0.1.0/pyproject.toml +83 -0
- django_md_docs-0.1.0/src/md_docs/__init__.py +1 -0
- django_md_docs-0.1.0/src/md_docs/apps.py +8 -0
- django_md_docs-0.1.0/src/md_docs/static/md_docs/css/page.css +310 -0
- django_md_docs-0.1.0/src/md_docs/templates/md_docs/page.html +41 -0
- django_md_docs-0.1.0/src/md_docs/urls.py +10 -0
- django_md_docs-0.1.0/src/md_docs/views.py +133 -0
|
@@ -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,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,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
|
+
)
|