django-app-help 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_app_help-0.1.0/MANIFEST.in +4 -0
- django_app_help-0.1.0/PKG-INFO +203 -0
- django_app_help-0.1.0/README.md +191 -0
- django_app_help-0.1.0/app_help/__init__.py +7 -0
- django_app_help-0.1.0/app_help/apps.py +5 -0
- django_app_help-0.1.0/app_help/conf.py +31 -0
- django_app_help-0.1.0/app_help/engine.py +180 -0
- django_app_help-0.1.0/app_help/exceptions.py +50 -0
- django_app_help-0.1.0/app_help/migrations/__init__.py +1 -0
- django_app_help-0.1.0/app_help/models.py +59 -0
- django_app_help-0.1.0/app_help/parsing.py +192 -0
- django_app_help-0.1.0/app_help/views.py +131 -0
- django_app_help-0.1.0/demo/demo/__init__.py +0 -0
- django_app_help-0.1.0/demo/demo/asgi.py +16 -0
- django_app_help-0.1.0/demo/demo/core/__init__.py +0 -0
- django_app_help-0.1.0/demo/demo/core/admin.py +3 -0
- django_app_help-0.1.0/demo/demo/core/apps.py +8 -0
- django_app_help-0.1.0/demo/demo/core/migrations/__init__.py +0 -0
- django_app_help-0.1.0/demo/demo/core/models.py +3 -0
- django_app_help-0.1.0/demo/demo/core/tests.py +42 -0
- django_app_help-0.1.0/demo/demo/core/views.py +188 -0
- django_app_help-0.1.0/demo/demo/settings.py +132 -0
- django_app_help-0.1.0/demo/demo/urls.py +56 -0
- django_app_help-0.1.0/demo/demo/wsgi.py +16 -0
- django_app_help-0.1.0/demo/manage.py +22 -0
- django_app_help-0.1.0/django_app_help.egg-info/PKG-INFO +203 -0
- django_app_help-0.1.0/django_app_help.egg-info/SOURCES.txt +32 -0
- django_app_help-0.1.0/django_app_help.egg-info/dependency_links.txt +1 -0
- django_app_help-0.1.0/django_app_help.egg-info/requires.txt +5 -0
- django_app_help-0.1.0/django_app_help.egg-info/top_level.txt +5 -0
- django_app_help-0.1.0/pyproject.toml +53 -0
- django_app_help-0.1.0/setup.cfg +4 -0
- django_app_help-0.1.0/tests/test_engine.py +99 -0
- django_app_help-0.1.0/tests/test_views.py +165 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-app-help
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Author-email: Caltech IMSS ADS <imss-ads-staff@caltech.edu>
|
|
5
|
+
Requires-Python: >=3.14
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: Django>=5.2
|
|
8
|
+
Requires-Dist: PyYAML>=6.0
|
|
9
|
+
Requires-Dist: django-markdownify>=0.9.7
|
|
10
|
+
Requires-Dist: django-wildewidgets>=1.7.2
|
|
11
|
+
Requires-Dist: pytest>=9.1.1
|
|
12
|
+
|
|
13
|
+
# Django App Help
|
|
14
|
+
|
|
15
|
+
Filesystem-backed Markdown help for Django applications. Help content lives in your Git repository, deploys read-only with the app, and renders in the browser through [django-wildewidgets](https://github.com/caltechads/django-wildewidgets).
|
|
16
|
+
|
|
17
|
+
Use it for in-app guidance, workflow documentation, field explanations, and operational notes — not as a full CMS.
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- Canonical Markdown pages under `help/pages/`
|
|
22
|
+
- Audience-specific **books** defined in YAML under `help/books/`
|
|
23
|
+
- Reusable snippets via `::include` directives
|
|
24
|
+
- Cross-page links with `help:` URLs and asset references with `asset:` URLs
|
|
25
|
+
- `HelpEngine` for loading, rendering, and validating content
|
|
26
|
+
- `HelpOffcanvasMixin` to attach a help panel to Wildewidgets views
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
Install the package and its runtime dependencies:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install django-app-help
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv add django-app-help
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or, from a checkout of this repository:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv sync
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Dependencies include Django, PyYAML, django-markdownify, and django-wildewidgets.
|
|
49
|
+
|
|
50
|
+
## Help content layout
|
|
51
|
+
|
|
52
|
+
Point Django at a help root directory with this structure:
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
help/
|
|
56
|
+
├── books/
|
|
57
|
+
│ ├── user.yaml
|
|
58
|
+
│ └── admin.yaml
|
|
59
|
+
├── pages/
|
|
60
|
+
│ ├── getting-started/
|
|
61
|
+
│ │ └── welcome.md
|
|
62
|
+
│ └── topics/
|
|
63
|
+
│ └── pages.md
|
|
64
|
+
├── snippets/
|
|
65
|
+
│ └── support-contact.md
|
|
66
|
+
└── assets/
|
|
67
|
+
└── images/
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
- **Page IDs** are paths under `pages/` without `.md` (for example `getting-started/welcome`).
|
|
71
|
+
- **Books** list page IDs in sections for different audiences. The same page can appear in multiple books.
|
|
72
|
+
- **Snippets** are included in pages with a whole-line directive:
|
|
73
|
+
|
|
74
|
+
```markdown
|
|
75
|
+
::include snippets/support-contact.md
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- **Links** reference other pages and assets:
|
|
79
|
+
|
|
80
|
+
```markdown
|
|
81
|
+
[Navigation](help:getting-started/navigation)
|
|
82
|
+

|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Pages may include YAML front matter (`title`, `summary`, `audience`, and so on). The engine strips front matter before rendering.
|
|
86
|
+
|
|
87
|
+
## Django setup
|
|
88
|
+
|
|
89
|
+
### Settings
|
|
90
|
+
|
|
91
|
+
Add the app (optional — there are no database models, but this keeps the integration explicit):
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
INSTALLED_APPS = [
|
|
95
|
+
# ...
|
|
96
|
+
"markdownify",
|
|
97
|
+
"wildewidgets",
|
|
98
|
+
"app_help",
|
|
99
|
+
]
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Set the filesystem help root and import the recommended Markdownify settings:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from pathlib import Path
|
|
106
|
+
|
|
107
|
+
from app_help.conf import MARKDOWNIFY # noqa: F401
|
|
108
|
+
|
|
109
|
+
APP_HELP_ROOT = Path(__file__).resolve().parent / "myapp" / "help"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`app_help.conf.MARKDOWNIFY` whitelists the HTML tags help pages need when rendered through django-markdownify.
|
|
113
|
+
|
|
114
|
+
### Wildewidgets views
|
|
115
|
+
|
|
116
|
+
Use `HelpOffcanvasMixin` on a Wildewidgets view. List it **before** `StandardWidgetMixin` so cooperative `get_context_data()` can build page content first, then wrap it with the help offcanvas:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from app_help.views import HelpOffcanvasMixin
|
|
120
|
+
from wildewidgets import MenuMixin, StandardWidgetMixin
|
|
121
|
+
from django.views.generic import TemplateView
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class MyPageView(HelpOffcanvasMixin, MenuMixin, StandardWidgetMixin, TemplateView):
|
|
125
|
+
help_page_id = "getting-started/welcome"
|
|
126
|
+
help_book_slug = "user" # optional; omit to skip book membership checks
|
|
127
|
+
help_offcanvas_title = "Help"
|
|
128
|
+
|
|
129
|
+
def get_content(self):
|
|
130
|
+
# return your Wildewidgets layout
|
|
131
|
+
...
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Configure help per view with class attributes:
|
|
135
|
+
|
|
136
|
+
| Attribute | Purpose |
|
|
137
|
+
|-----------|---------|
|
|
138
|
+
| `help_root` | Override `settings.APP_HELP_ROOT` |
|
|
139
|
+
| `help_page_id` | Page to render (required) |
|
|
140
|
+
| `help_book_slug` | Require the page to be listed in this book |
|
|
141
|
+
| `help_offcanvas_id` | DOM id for the offcanvas (default `help-offcanvas`) |
|
|
142
|
+
| `help_offcanvas_title` | Panel title (default `Help`) |
|
|
143
|
+
|
|
144
|
+
Override `get_help_root()`, `get_help_page_id()`, or `get_help_book_slug()` when the active page depends on the request.
|
|
145
|
+
|
|
146
|
+
### Programmatic use
|
|
147
|
+
|
|
148
|
+
Render or validate content without a view:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from app_help import HelpEngine
|
|
152
|
+
|
|
153
|
+
engine = HelpEngine("/path/to/help")
|
|
154
|
+
|
|
155
|
+
markdown = engine.render_page("getting-started/welcome", book_slug="user")
|
|
156
|
+
page = engine.load_page("getting-started/welcome")
|
|
157
|
+
book = engine.load_book("user")
|
|
158
|
+
|
|
159
|
+
engine.validate_page("getting-started/welcome")
|
|
160
|
+
engine.validate_book("user")
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`render_page()` expands snippet includes, strips front matter, and optionally verifies that the page belongs to the requested book.
|
|
164
|
+
|
|
165
|
+
## Demo
|
|
166
|
+
|
|
167
|
+
The `demo/` directory is a small Django project that shows help wired into Academy-themed Wildewidgets pages. It installs this package in editable mode and serves sample content from `demo/demo/core/help/`.
|
|
168
|
+
|
|
169
|
+
### Run the demo
|
|
170
|
+
|
|
171
|
+
From the repository root:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
uv sync
|
|
175
|
+
cd demo
|
|
176
|
+
uv sync
|
|
177
|
+
uv run python manage.py runserver
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Open http://127.0.0.1:8000/. Each sidebar route renders a static demo page with a help offcanvas:
|
|
181
|
+
|
|
182
|
+
| Route | Help book | Help page |
|
|
183
|
+
|-------|-----------|-----------|
|
|
184
|
+
| `/` | `user` | `getting-started/welcome` |
|
|
185
|
+
| `/components/` | `user` | `topics/pages` |
|
|
186
|
+
| `/workflow/` | `developer` | `authoring/pages` |
|
|
187
|
+
| `/status/` | `admin` | `admin/validation` |
|
|
188
|
+
|
|
189
|
+
Browse the Markdown under `demo/demo/core/help/` to see books, pages, snippets, includes, and link patterns in context. `demo/demo/core/views.py` shows how `HelpOffcanvasMixin` composes with `StandardWidgetMixin`.
|
|
190
|
+
|
|
191
|
+
## Development
|
|
192
|
+
|
|
193
|
+
Run the library test suite from the repository root:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
make pytest
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Pass extra pytest arguments after the target:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
make pytest ARGS="tests/test_engine.py -v"
|
|
203
|
+
```
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# Django App Help
|
|
2
|
+
|
|
3
|
+
Filesystem-backed Markdown help for Django applications. Help content lives in your Git repository, deploys read-only with the app, and renders in the browser through [django-wildewidgets](https://github.com/caltechads/django-wildewidgets).
|
|
4
|
+
|
|
5
|
+
Use it for in-app guidance, workflow documentation, field explanations, and operational notes — not as a full CMS.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Canonical Markdown pages under `help/pages/`
|
|
10
|
+
- Audience-specific **books** defined in YAML under `help/books/`
|
|
11
|
+
- Reusable snippets via `::include` directives
|
|
12
|
+
- Cross-page links with `help:` URLs and asset references with `asset:` URLs
|
|
13
|
+
- `HelpEngine` for loading, rendering, and validating content
|
|
14
|
+
- `HelpOffcanvasMixin` to attach a help panel to Wildewidgets views
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
Install the package and its runtime dependencies:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install django-app-help
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv add django-app-help
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or, from a checkout of this repository:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv sync
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Dependencies include Django, PyYAML, django-markdownify, and django-wildewidgets.
|
|
37
|
+
|
|
38
|
+
## Help content layout
|
|
39
|
+
|
|
40
|
+
Point Django at a help root directory with this structure:
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
help/
|
|
44
|
+
├── books/
|
|
45
|
+
│ ├── user.yaml
|
|
46
|
+
│ └── admin.yaml
|
|
47
|
+
├── pages/
|
|
48
|
+
│ ├── getting-started/
|
|
49
|
+
│ │ └── welcome.md
|
|
50
|
+
│ └── topics/
|
|
51
|
+
│ └── pages.md
|
|
52
|
+
├── snippets/
|
|
53
|
+
│ └── support-contact.md
|
|
54
|
+
└── assets/
|
|
55
|
+
└── images/
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- **Page IDs** are paths under `pages/` without `.md` (for example `getting-started/welcome`).
|
|
59
|
+
- **Books** list page IDs in sections for different audiences. The same page can appear in multiple books.
|
|
60
|
+
- **Snippets** are included in pages with a whole-line directive:
|
|
61
|
+
|
|
62
|
+
```markdown
|
|
63
|
+
::include snippets/support-contact.md
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- **Links** reference other pages and assets:
|
|
67
|
+
|
|
68
|
+
```markdown
|
|
69
|
+
[Navigation](help:getting-started/navigation)
|
|
70
|
+

|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Pages may include YAML front matter (`title`, `summary`, `audience`, and so on). The engine strips front matter before rendering.
|
|
74
|
+
|
|
75
|
+
## Django setup
|
|
76
|
+
|
|
77
|
+
### Settings
|
|
78
|
+
|
|
79
|
+
Add the app (optional — there are no database models, but this keeps the integration explicit):
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
INSTALLED_APPS = [
|
|
83
|
+
# ...
|
|
84
|
+
"markdownify",
|
|
85
|
+
"wildewidgets",
|
|
86
|
+
"app_help",
|
|
87
|
+
]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Set the filesystem help root and import the recommended Markdownify settings:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from pathlib import Path
|
|
94
|
+
|
|
95
|
+
from app_help.conf import MARKDOWNIFY # noqa: F401
|
|
96
|
+
|
|
97
|
+
APP_HELP_ROOT = Path(__file__).resolve().parent / "myapp" / "help"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`app_help.conf.MARKDOWNIFY` whitelists the HTML tags help pages need when rendered through django-markdownify.
|
|
101
|
+
|
|
102
|
+
### Wildewidgets views
|
|
103
|
+
|
|
104
|
+
Use `HelpOffcanvasMixin` on a Wildewidgets view. List it **before** `StandardWidgetMixin` so cooperative `get_context_data()` can build page content first, then wrap it with the help offcanvas:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from app_help.views import HelpOffcanvasMixin
|
|
108
|
+
from wildewidgets import MenuMixin, StandardWidgetMixin
|
|
109
|
+
from django.views.generic import TemplateView
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class MyPageView(HelpOffcanvasMixin, MenuMixin, StandardWidgetMixin, TemplateView):
|
|
113
|
+
help_page_id = "getting-started/welcome"
|
|
114
|
+
help_book_slug = "user" # optional; omit to skip book membership checks
|
|
115
|
+
help_offcanvas_title = "Help"
|
|
116
|
+
|
|
117
|
+
def get_content(self):
|
|
118
|
+
# return your Wildewidgets layout
|
|
119
|
+
...
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Configure help per view with class attributes:
|
|
123
|
+
|
|
124
|
+
| Attribute | Purpose |
|
|
125
|
+
|-----------|---------|
|
|
126
|
+
| `help_root` | Override `settings.APP_HELP_ROOT` |
|
|
127
|
+
| `help_page_id` | Page to render (required) |
|
|
128
|
+
| `help_book_slug` | Require the page to be listed in this book |
|
|
129
|
+
| `help_offcanvas_id` | DOM id for the offcanvas (default `help-offcanvas`) |
|
|
130
|
+
| `help_offcanvas_title` | Panel title (default `Help`) |
|
|
131
|
+
|
|
132
|
+
Override `get_help_root()`, `get_help_page_id()`, or `get_help_book_slug()` when the active page depends on the request.
|
|
133
|
+
|
|
134
|
+
### Programmatic use
|
|
135
|
+
|
|
136
|
+
Render or validate content without a view:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from app_help import HelpEngine
|
|
140
|
+
|
|
141
|
+
engine = HelpEngine("/path/to/help")
|
|
142
|
+
|
|
143
|
+
markdown = engine.render_page("getting-started/welcome", book_slug="user")
|
|
144
|
+
page = engine.load_page("getting-started/welcome")
|
|
145
|
+
book = engine.load_book("user")
|
|
146
|
+
|
|
147
|
+
engine.validate_page("getting-started/welcome")
|
|
148
|
+
engine.validate_book("user")
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`render_page()` expands snippet includes, strips front matter, and optionally verifies that the page belongs to the requested book.
|
|
152
|
+
|
|
153
|
+
## Demo
|
|
154
|
+
|
|
155
|
+
The `demo/` directory is a small Django project that shows help wired into Academy-themed Wildewidgets pages. It installs this package in editable mode and serves sample content from `demo/demo/core/help/`.
|
|
156
|
+
|
|
157
|
+
### Run the demo
|
|
158
|
+
|
|
159
|
+
From the repository root:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
uv sync
|
|
163
|
+
cd demo
|
|
164
|
+
uv sync
|
|
165
|
+
uv run python manage.py runserver
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Open http://127.0.0.1:8000/. Each sidebar route renders a static demo page with a help offcanvas:
|
|
169
|
+
|
|
170
|
+
| Route | Help book | Help page |
|
|
171
|
+
|-------|-----------|-----------|
|
|
172
|
+
| `/` | `user` | `getting-started/welcome` |
|
|
173
|
+
| `/components/` | `user` | `topics/pages` |
|
|
174
|
+
| `/workflow/` | `developer` | `authoring/pages` |
|
|
175
|
+
| `/status/` | `admin` | `admin/validation` |
|
|
176
|
+
|
|
177
|
+
Browse the Markdown under `demo/demo/core/help/` to see books, pages, snippets, includes, and link patterns in context. `demo/demo/core/views.py` shows how `HelpOffcanvasMixin` composes with `StandardWidgetMixin`.
|
|
178
|
+
|
|
179
|
+
## Development
|
|
180
|
+
|
|
181
|
+
Run the library test suite from the repository root:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
make pytest
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Pass extra pytest arguments after the target:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
make pytest ARGS="tests/test_engine.py -v"
|
|
191
|
+
```
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Recommended Django settings for django-app-help integrations."""
|
|
2
|
+
|
|
3
|
+
import bleach
|
|
4
|
+
|
|
5
|
+
#: Bleach tag whitelist for Markdown help content rendered via django-markdownify.
|
|
6
|
+
_MARKDOWN_HELP_TAGS = {
|
|
7
|
+
*bleach.sanitizer.ALLOWED_TAGS,
|
|
8
|
+
"h1",
|
|
9
|
+
"h2",
|
|
10
|
+
"h3",
|
|
11
|
+
"h4",
|
|
12
|
+
"h5",
|
|
13
|
+
"h6",
|
|
14
|
+
"hr",
|
|
15
|
+
"p",
|
|
16
|
+
"pre",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
#: Recommended ``MARKDOWNIFY`` settings for help pages rendered with Wildewidgets.
|
|
20
|
+
MARKDOWNIFY = {
|
|
21
|
+
"default": {
|
|
22
|
+
"WHITELIST_TAGS": sorted(_MARKDOWN_HELP_TAGS),
|
|
23
|
+
"MARKDOWN_EXTENSIONS": [
|
|
24
|
+
"markdown.extensions.fenced_code",
|
|
25
|
+
],
|
|
26
|
+
"LINKIFY_TEXT": {
|
|
27
|
+
"PARSE_URLS": True,
|
|
28
|
+
"SKIP_TAGS": ["pre", "code"],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Public Markdown rendering engine for filesystem-backed help content."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path, PurePosixPath
|
|
4
|
+
|
|
5
|
+
from app_help.exceptions import HelpBookNotFoundError, HelpPageNotFoundError, PageNotInBookError
|
|
6
|
+
from app_help.models import Book, Page
|
|
7
|
+
from app_help.parsing import expand_includes, parse_book, parse_front_matter, validate_asset_links, validate_help_links
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HelpEngine:
|
|
11
|
+
"""Load help books and render canonical Markdown pages from disk.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
root_path: Help root containing ``books``, ``pages``, ``snippets``, and ``assets`` directories.
|
|
15
|
+
max_include_depth: Maximum nested snippet include depth.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, root_path: str | Path, max_include_depth: int = 5) -> None:
|
|
19
|
+
#: Help root containing content directories.
|
|
20
|
+
self.root_path = Path(root_path)
|
|
21
|
+
#: Maximum nested snippet include depth.
|
|
22
|
+
self.max_include_depth = max_include_depth
|
|
23
|
+
#: Directory containing book YAML files.
|
|
24
|
+
self.books_path = self.root_path / "books"
|
|
25
|
+
#: Directory containing canonical Markdown pages.
|
|
26
|
+
self.pages_path = self.root_path / "pages"
|
|
27
|
+
#: Directory containing help assets.
|
|
28
|
+
self.assets_path = self.root_path / "assets"
|
|
29
|
+
|
|
30
|
+
def render_page(self, page_id: str, book_slug: str | None = None) -> str:
|
|
31
|
+
"""Return expanded Markdown for a page.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
page_id: Logical page ID, such as ``billing/overview``.
|
|
35
|
+
book_slug: Optional book slug requiring the page to be listed in that book.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
HelpBookNotFoundError: If ``book_slug`` is provided and missing.
|
|
39
|
+
HelpPageNotFoundError: If the page file is missing.
|
|
40
|
+
PageNotInBookError: If the book does not reference the page.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Markdown with front matter removed and snippets expanded.
|
|
44
|
+
"""
|
|
45
|
+
if book_slug is not None:
|
|
46
|
+
book = self.load_book(book_slug)
|
|
47
|
+
if not book.contains_page(page_id):
|
|
48
|
+
raise PageNotInBookError(f"{page_id} is not listed in book {book_slug}")
|
|
49
|
+
|
|
50
|
+
page = self.load_page(page_id)
|
|
51
|
+
markdown = expand_includes(page.markdown, self.root_path, self.max_include_depth)
|
|
52
|
+
return markdown.rstrip() + "\n"
|
|
53
|
+
|
|
54
|
+
def load_page(self, page_id: str) -> Page:
|
|
55
|
+
"""Load a canonical page from disk.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
page_id: Logical page ID.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
HelpPageNotFoundError: If the page file is missing.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Parsed page.
|
|
65
|
+
"""
|
|
66
|
+
path = self._page_path(page_id)
|
|
67
|
+
if not path.is_file():
|
|
68
|
+
raise HelpPageNotFoundError(f"page not found: {page_id}")
|
|
69
|
+
|
|
70
|
+
metadata, markdown = parse_front_matter(path.read_text(encoding="utf-8"), path)
|
|
71
|
+
return Page(page_id=page_id, path=path, metadata=metadata, markdown=markdown)
|
|
72
|
+
|
|
73
|
+
def load_book(self, slug: str) -> Book:
|
|
74
|
+
"""Load a book by slug.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
slug: Book slug.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
HelpBookNotFoundError: If the book file is missing.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Parsed book.
|
|
84
|
+
"""
|
|
85
|
+
path = self._book_path(slug)
|
|
86
|
+
if not path.is_file():
|
|
87
|
+
raise HelpBookNotFoundError(f"book not found: {slug}")
|
|
88
|
+
return parse_book(path)
|
|
89
|
+
|
|
90
|
+
def validate_page(self, page_id: str) -> None:
|
|
91
|
+
"""Validate includes, help links, and asset links for one page.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
page_id: Logical page ID.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
HelpAssetNotFoundError: If an asset reference is missing.
|
|
98
|
+
HelpLinkNotFoundError: If a help link points to a missing page.
|
|
99
|
+
HelpPageNotFoundError: If the page file is missing.
|
|
100
|
+
"""
|
|
101
|
+
markdown = self.render_page(page_id)
|
|
102
|
+
validate_help_links(markdown, self._page_exists)
|
|
103
|
+
validate_asset_links(markdown, self.assets_path)
|
|
104
|
+
|
|
105
|
+
def validate_book(self, slug: str) -> None:
|
|
106
|
+
"""Validate that all pages referenced by a book exist and pass page validation.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
slug: Book slug.
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
HelpBookNotFoundError: If the book file is missing.
|
|
113
|
+
HelpPageNotFoundError: If a referenced page is missing.
|
|
114
|
+
"""
|
|
115
|
+
book = self.load_book(slug)
|
|
116
|
+
for page_id in book.pages:
|
|
117
|
+
self.validate_page(page_id)
|
|
118
|
+
|
|
119
|
+
def _page_path(self, page_id: str) -> Path:
|
|
120
|
+
"""Return the filesystem path for a safe page ID.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
page_id: Logical page ID.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
HelpPageNotFoundError: If the page ID is unsafe.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Markdown file path.
|
|
130
|
+
"""
|
|
131
|
+
rel = self._safe_relative(page_id, HelpPageNotFoundError)
|
|
132
|
+
return self.pages_path / rel.with_suffix(".md")
|
|
133
|
+
|
|
134
|
+
def _book_path(self, slug: str) -> Path:
|
|
135
|
+
"""Return the filesystem path for a safe book slug.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
slug: Book slug.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
HelpBookNotFoundError: If the slug is unsafe.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
YAML file path.
|
|
145
|
+
"""
|
|
146
|
+
rel = self._safe_relative(slug, HelpBookNotFoundError)
|
|
147
|
+
return self.books_path / rel.with_suffix(".yaml")
|
|
148
|
+
|
|
149
|
+
def _page_exists(self, page_id: str) -> bool:
|
|
150
|
+
"""Return whether a page ID resolves to an existing file.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
page_id: Logical page ID.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
True when the page exists.
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
return self._page_path(page_id).is_file()
|
|
160
|
+
except HelpPageNotFoundError:
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _safe_relative(value: str, error_type: type[Exception]) -> Path:
|
|
165
|
+
"""Convert a logical ID into a safe relative filesystem path.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
value: Logical ID or slug.
|
|
169
|
+
error_type: Exception class to raise for unsafe values.
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
Exception: The supplied ``error_type`` when ``value`` is unsafe.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Relative path.
|
|
176
|
+
"""
|
|
177
|
+
rel = PurePosixPath(value)
|
|
178
|
+
if rel.is_absolute() or ".." in rel.parts or not rel.parts or any(part == "" for part in rel.parts):
|
|
179
|
+
raise error_type(f"unsafe help path: {value}")
|
|
180
|
+
return Path(*rel.parts)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Exceptions raised by the file-based help engine."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class HelpError(Exception):
|
|
5
|
+
"""Base class for help engine errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HelpPageNotFoundError(HelpError):
|
|
9
|
+
"""Raised when a requested help page does not exist."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HelpBookNotFoundError(HelpError):
|
|
13
|
+
"""Raised when a requested help book does not exist."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PageNotInBookError(HelpError):
|
|
17
|
+
"""Raised when a book-scoped page request is not listed in the book."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InvalidIncludeError(HelpError):
|
|
21
|
+
"""Raised when an include directive points outside allowed snippets."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class IncludeNotFoundError(HelpError):
|
|
25
|
+
"""Raised when an include directive points to a missing snippet."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CircularIncludeError(HelpError):
|
|
29
|
+
"""Raised when snippet includes form a cycle."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class IncludeDepthError(HelpError):
|
|
33
|
+
"""Raised when snippet includes exceed the configured depth."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InvalidBookError(HelpError):
|
|
37
|
+
"""Raised when a book YAML file is missing required structure."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class InvalidFrontMatterError(HelpError):
|
|
41
|
+
"""Raised when page front matter is not valid YAML metadata."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class HelpLinkNotFoundError(HelpError):
|
|
45
|
+
"""Raised when a rendered page links to a missing help page."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class HelpAssetNotFoundError(HelpError):
|
|
49
|
+
"""Raised when a rendered page references a missing asset."""
|
|
50
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.1"
|