django-app-help 0.1.0__py3-none-any.whl
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.
- app_help/__init__.py +7 -0
- app_help/apps.py +5 -0
- app_help/conf.py +31 -0
- app_help/engine.py +180 -0
- app_help/exceptions.py +50 -0
- app_help/migrations/__init__.py +1 -0
- app_help/models.py +59 -0
- app_help/parsing.py +192 -0
- app_help/views.py +131 -0
- demo/demo/__init__.py +0 -0
- demo/demo/asgi.py +16 -0
- demo/demo/core/__init__.py +0 -0
- demo/demo/core/admin.py +3 -0
- demo/demo/core/apps.py +8 -0
- demo/demo/core/migrations/__init__.py +0 -0
- demo/demo/core/models.py +3 -0
- demo/demo/core/tests.py +42 -0
- demo/demo/core/views.py +188 -0
- demo/demo/settings.py +132 -0
- demo/demo/urls.py +56 -0
- demo/demo/wsgi.py +16 -0
- demo/manage.py +22 -0
- django_app_help-0.1.0.dist-info/METADATA +203 -0
- django_app_help-0.1.0.dist-info/RECORD +26 -0
- django_app_help-0.1.0.dist-info/WHEEL +5 -0
- django_app_help-0.1.0.dist-info/top_level.txt +2 -0
app_help/__init__.py
ADDED
app_help/apps.py
ADDED
app_help/conf.py
ADDED
|
@@ -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
|
+
}
|
app_help/engine.py
ADDED
|
@@ -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)
|
app_help/exceptions.py
ADDED
|
@@ -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"
|
app_help/models.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Data models for parsed filesystem help content."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Page:
|
|
10
|
+
"""Canonical Markdown help page loaded from disk.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
page_id: Logical page ID, such as ``billing/overview``.
|
|
14
|
+
path: Source Markdown file path.
|
|
15
|
+
metadata: YAML front matter values.
|
|
16
|
+
markdown: Markdown body with front matter removed.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
#: Logical page ID, such as ``billing/overview``.
|
|
20
|
+
page_id: str
|
|
21
|
+
#: Source Markdown file path.
|
|
22
|
+
path: Path
|
|
23
|
+
#: YAML front matter values.
|
|
24
|
+
metadata: dict[str, Any]
|
|
25
|
+
#: Markdown body with front matter removed.
|
|
26
|
+
markdown: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Book:
|
|
31
|
+
"""YAML-defined reading path that references canonical pages.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
slug: Book slug used for lookup.
|
|
35
|
+
path: Source YAML file path.
|
|
36
|
+
metadata: Parsed YAML values.
|
|
37
|
+
pages: Flattened page IDs referenced by all sections.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
#: Book slug used for lookup.
|
|
41
|
+
slug: str
|
|
42
|
+
#: Source YAML file path.
|
|
43
|
+
path: Path
|
|
44
|
+
#: Parsed YAML values.
|
|
45
|
+
metadata: dict[str, Any]
|
|
46
|
+
#: Flattened page IDs referenced by all sections.
|
|
47
|
+
pages: tuple[str, ...]
|
|
48
|
+
|
|
49
|
+
def contains_page(self, page_id: str) -> bool:
|
|
50
|
+
"""Return whether the book references a page.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
page_id: Logical page ID to find.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
True when the page appears in any book section.
|
|
57
|
+
"""
|
|
58
|
+
return page_id in self.pages
|
|
59
|
+
|
app_help/parsing.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Parsing helpers for Markdown help pages, books, and directives."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path, PurePosixPath
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from app_help.exceptions import (
|
|
10
|
+
CircularIncludeError,
|
|
11
|
+
HelpAssetNotFoundError,
|
|
12
|
+
HelpLinkNotFoundError,
|
|
13
|
+
IncludeDepthError,
|
|
14
|
+
IncludeNotFoundError,
|
|
15
|
+
InvalidBookError,
|
|
16
|
+
InvalidFrontMatterError,
|
|
17
|
+
InvalidIncludeError,
|
|
18
|
+
)
|
|
19
|
+
from app_help.models import Book
|
|
20
|
+
|
|
21
|
+
#: Whole-line include directive matcher.
|
|
22
|
+
INCLUDE_RE = re.compile(r"^::include\s+(.+?)\s*(?:\n|$)", re.MULTILINE)
|
|
23
|
+
#: Markdown help link target matcher.
|
|
24
|
+
HELP_LINK_RE = re.compile(r"\]\(help:([^)#]+)(?:#[^)]+)?\)")
|
|
25
|
+
#: Markdown asset link target matcher.
|
|
26
|
+
ASSET_LINK_RE = re.compile(r"\]\(asset:([^)]+)\)")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_front_matter(markdown: str, source: Path) -> tuple[dict[str, Any], str]:
|
|
30
|
+
"""Split YAML front matter from a Markdown document.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
markdown: Raw Markdown file contents.
|
|
34
|
+
source: Source path used in error messages.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
InvalidFrontMatterError: If front matter is malformed or not a mapping.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Metadata and Markdown body.
|
|
41
|
+
"""
|
|
42
|
+
lines = markdown.splitlines(keepends=True)
|
|
43
|
+
if not lines or lines[0].strip() != "---":
|
|
44
|
+
return {}, markdown
|
|
45
|
+
|
|
46
|
+
end_index = None
|
|
47
|
+
for index, line in enumerate(lines[1:], start=1):
|
|
48
|
+
if line.strip() == "---":
|
|
49
|
+
end_index = index
|
|
50
|
+
break
|
|
51
|
+
|
|
52
|
+
if end_index is None:
|
|
53
|
+
raise InvalidFrontMatterError(f"{source} has unterminated front matter")
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
metadata = yaml.safe_load("".join(lines[1:end_index])) or {}
|
|
57
|
+
except yaml.YAMLError as exc:
|
|
58
|
+
raise InvalidFrontMatterError(f"{source} has invalid front matter") from exc
|
|
59
|
+
|
|
60
|
+
if not isinstance(metadata, dict):
|
|
61
|
+
raise InvalidFrontMatterError(f"{source} front matter must be a mapping")
|
|
62
|
+
|
|
63
|
+
return metadata, "".join(lines[end_index + 1 :]).lstrip("\n")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_book(path: Path) -> Book:
|
|
67
|
+
"""Parse a YAML help book definition.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
path: Book YAML file path.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
InvalidBookError: If YAML or book structure is invalid.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Parsed book.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
80
|
+
except yaml.YAMLError as exc:
|
|
81
|
+
raise InvalidBookError(f"{path} is not valid YAML") from exc
|
|
82
|
+
|
|
83
|
+
if not isinstance(data, dict):
|
|
84
|
+
raise InvalidBookError(f"{path} must contain a YAML mapping")
|
|
85
|
+
|
|
86
|
+
sections = data.get("sections", [])
|
|
87
|
+
if not isinstance(sections, list):
|
|
88
|
+
raise InvalidBookError(f"{path} sections must be a list")
|
|
89
|
+
|
|
90
|
+
pages: list[str] = []
|
|
91
|
+
for section in sections:
|
|
92
|
+
if not isinstance(section, dict):
|
|
93
|
+
raise InvalidBookError(f"{path} section must be a mapping")
|
|
94
|
+
section_pages = section.get("pages", [])
|
|
95
|
+
if not isinstance(section_pages, list) or not all(isinstance(page, str) for page in section_pages):
|
|
96
|
+
raise InvalidBookError(f"{path} section pages must be strings")
|
|
97
|
+
pages.extend(section_pages)
|
|
98
|
+
|
|
99
|
+
slug = data.get("slug", path.stem)
|
|
100
|
+
if not isinstance(slug, str):
|
|
101
|
+
raise InvalidBookError(f"{path} slug must be a string")
|
|
102
|
+
|
|
103
|
+
return Book(slug=slug, path=path, metadata=data, pages=tuple(pages))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def expand_includes(markdown: str, root_path: Path, max_depth: int, depth: int = 0, stack: tuple[Path, ...] = ()) -> str:
|
|
107
|
+
"""Expand snippet include directives in Markdown.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
markdown: Markdown text to expand.
|
|
111
|
+
root_path: Help root containing the ``snippets`` directory.
|
|
112
|
+
max_depth: Maximum nested include depth.
|
|
113
|
+
depth: Current include depth.
|
|
114
|
+
stack: Include files currently being expanded.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
CircularIncludeError: If snippets include each other cyclically.
|
|
118
|
+
IncludeDepthError: If expansion exceeds ``max_depth``.
|
|
119
|
+
IncludeNotFoundError: If a snippet file is missing.
|
|
120
|
+
InvalidIncludeError: If an include target is unsafe.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Markdown with snippets expanded.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def replace(match: re.Match[str]) -> str:
|
|
127
|
+
target = match.group(1).strip()
|
|
128
|
+
snippet_path = resolve_include_path(root_path, target)
|
|
129
|
+
|
|
130
|
+
if depth >= max_depth:
|
|
131
|
+
raise IncludeDepthError(f"include depth exceeded at {target}")
|
|
132
|
+
if snippet_path in stack:
|
|
133
|
+
raise CircularIncludeError(f"circular include at {target}")
|
|
134
|
+
if not snippet_path.is_file():
|
|
135
|
+
raise IncludeNotFoundError(f"include not found: {target}")
|
|
136
|
+
|
|
137
|
+
nested = snippet_path.read_text(encoding="utf-8")
|
|
138
|
+
return expand_includes(nested, root_path, max_depth, depth + 1, stack + (snippet_path,))
|
|
139
|
+
|
|
140
|
+
return INCLUDE_RE.sub(replace, markdown)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def resolve_include_path(root_path: Path, target: str) -> Path:
|
|
144
|
+
"""Resolve a snippet include target.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
root_path: Help root path.
|
|
148
|
+
target: Include target from a directive.
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
InvalidIncludeError: If the target is absolute, traverses upward, or is not under snippets.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Absolute snippet path.
|
|
155
|
+
"""
|
|
156
|
+
rel = PurePosixPath(target)
|
|
157
|
+
if rel.is_absolute() or ".." in rel.parts or not rel.parts or rel.parts[0] != "snippets":
|
|
158
|
+
raise InvalidIncludeError(f"invalid include target: {target}")
|
|
159
|
+
return root_path / Path(*rel.parts)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def validate_help_links(markdown: str, page_exists: Any) -> None:
|
|
163
|
+
"""Validate ``help:`` links in Markdown.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
markdown: Markdown text to scan.
|
|
167
|
+
page_exists: Callable accepting a page ID and returning a boolean.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
HelpLinkNotFoundError: If a linked page does not exist.
|
|
171
|
+
"""
|
|
172
|
+
for match in HELP_LINK_RE.finditer(markdown):
|
|
173
|
+
page_id = match.group(1)
|
|
174
|
+
if not page_exists(page_id):
|
|
175
|
+
raise HelpLinkNotFoundError(f"help link target not found: {page_id}")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def validate_asset_links(markdown: str, assets_path: Path) -> None:
|
|
179
|
+
"""Validate ``asset:`` links in Markdown.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
markdown: Markdown text to scan.
|
|
183
|
+
assets_path: Help assets directory.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
HelpAssetNotFoundError: If a referenced asset is missing or unsafe.
|
|
187
|
+
"""
|
|
188
|
+
for match in ASSET_LINK_RE.finditer(markdown):
|
|
189
|
+
target = match.group(1)
|
|
190
|
+
rel = PurePosixPath(target)
|
|
191
|
+
if rel.is_absolute() or ".." in rel.parts or not (assets_path / Path(*rel.parts)).is_file():
|
|
192
|
+
raise HelpAssetNotFoundError(f"asset target not found: {target}")
|
app_help/views.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Django view mixins for rendering help content in Wildewidgets views."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, ClassVar
|
|
7
|
+
|
|
8
|
+
from django.conf import settings
|
|
9
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
10
|
+
|
|
11
|
+
from app_help.engine import HelpEngine
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HelpOffcanvasMixin:
|
|
15
|
+
"""Wrap Wildewidgets view content with a help offcanvas.
|
|
16
|
+
|
|
17
|
+
List this mixin **before** :class:`wildewidgets.StandardWidgetMixin` so
|
|
18
|
+
cooperative ``get_context_data()`` can descend through
|
|
19
|
+
``StandardWidgetMixin`` first (which calls the view's ``get_content()``)
|
|
20
|
+
and then wrap the resulting ``content`` context value on the way back up.
|
|
21
|
+
|
|
22
|
+
Example::
|
|
23
|
+
|
|
24
|
+
class PageView(HelpOffcanvasMixin, MenuMixin, StandardWidgetMixin, TemplateView):
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
The view must provide a ``content`` context value by the time
|
|
28
|
+
``super().get_context_data()`` returns.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
#: Optional filesystem root containing help content.
|
|
32
|
+
help_root: ClassVar[str | Path | None] = None
|
|
33
|
+
#: Help book slug used to constrain the rendered page.
|
|
34
|
+
help_book_slug: ClassVar[str | None] = None
|
|
35
|
+
#: Help page id to render.
|
|
36
|
+
help_page_id: ClassVar[str | None] = None
|
|
37
|
+
#: DOM id used by the help offcanvas and trigger.
|
|
38
|
+
help_offcanvas_id: ClassVar[str] = "help-offcanvas"
|
|
39
|
+
#: Header title shown in the help offcanvas.
|
|
40
|
+
help_offcanvas_title: ClassVar[str] = "Help"
|
|
41
|
+
#: CSS classes applied around rendered Markdown content.
|
|
42
|
+
help_markdown_css_class: ClassVar[str] = ""
|
|
43
|
+
|
|
44
|
+
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
|
45
|
+
"""Wrap the content context widget with the help offcanvas.
|
|
46
|
+
|
|
47
|
+
Keyword Args:
|
|
48
|
+
**kwargs: Extra context values.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
ImproperlyConfigured: If no ``content`` context value exists.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Context with ``content`` replaced by a wrapping block.
|
|
55
|
+
"""
|
|
56
|
+
context = super().get_context_data(**kwargs) # type: ignore[misc]
|
|
57
|
+
content = context.get("content")
|
|
58
|
+
if content is None:
|
|
59
|
+
msg = "HelpOffcanvasMixin requires a content context value."
|
|
60
|
+
raise ImproperlyConfigured(msg)
|
|
61
|
+
|
|
62
|
+
from wildewidgets import Block
|
|
63
|
+
|
|
64
|
+
context["content"] = Block(content, self.get_help_offcanvas())
|
|
65
|
+
return context
|
|
66
|
+
|
|
67
|
+
def get_help_root(self) -> str | Path:
|
|
68
|
+
"""Return the help root for the current view.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
ImproperlyConfigured: If neither ``help_root`` nor ``APP_HELP_ROOT`` is set.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Filesystem help root.
|
|
75
|
+
"""
|
|
76
|
+
root = self.help_root if self.help_root is not None else getattr(settings, "APP_HELP_ROOT", None)
|
|
77
|
+
if not isinstance(root, str | Path) or root == "":
|
|
78
|
+
msg = "HelpOffcanvasMixin requires help_root or settings.APP_HELP_ROOT."
|
|
79
|
+
raise ImproperlyConfigured(msg)
|
|
80
|
+
return root
|
|
81
|
+
|
|
82
|
+
def get_help_page_id(self) -> str:
|
|
83
|
+
"""Return the help page id for the current view.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ImproperlyConfigured: If ``help_page_id`` is not set.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Help page id.
|
|
90
|
+
"""
|
|
91
|
+
if not self.help_page_id:
|
|
92
|
+
msg = "HelpOffcanvasMixin requires help_page_id."
|
|
93
|
+
raise ImproperlyConfigured(msg)
|
|
94
|
+
return self.help_page_id
|
|
95
|
+
|
|
96
|
+
def get_help_book_slug(self) -> str | None:
|
|
97
|
+
"""Return the optional help book slug.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Help book slug, or ``None`` for unscoped page rendering.
|
|
101
|
+
"""
|
|
102
|
+
return self.help_book_slug
|
|
103
|
+
|
|
104
|
+
def get_help_markdown(self) -> str:
|
|
105
|
+
"""Render Markdown for the configured help page.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Rendered Markdown with includes expanded.
|
|
109
|
+
"""
|
|
110
|
+
return HelpEngine(self.get_help_root()).render_page(
|
|
111
|
+
self.get_help_page_id(),
|
|
112
|
+
book_slug=self.get_help_book_slug(),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def get_help_offcanvas(self) -> Any:
|
|
116
|
+
"""Build the help offcanvas widget.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Wildewidgets offcanvas widget containing rendered Markdown.
|
|
120
|
+
"""
|
|
121
|
+
from wildewidgets import MarkdownWidget, OffcanvasWidget
|
|
122
|
+
|
|
123
|
+
markdown = MarkdownWidget(
|
|
124
|
+
text=self.get_help_markdown(),
|
|
125
|
+
css_class=self.help_markdown_css_class,
|
|
126
|
+
)
|
|
127
|
+
return OffcanvasWidget(
|
|
128
|
+
offcanvas_id=self.help_offcanvas_id,
|
|
129
|
+
offcanvas_title=self.help_offcanvas_title,
|
|
130
|
+
widget=markdown,
|
|
131
|
+
)
|
demo/demo/__init__.py
ADDED
|
File without changes
|
demo/demo/asgi.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ASGI config for demo project.
|
|
3
|
+
|
|
4
|
+
It exposes the ASGI callable as a module-level variable named ``application``.
|
|
5
|
+
|
|
6
|
+
For more information on this file, see
|
|
7
|
+
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from django.core.asgi import get_asgi_application
|
|
13
|
+
|
|
14
|
+
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'demo.settings')
|
|
15
|
+
|
|
16
|
+
application = get_asgi_application()
|
|
File without changes
|
demo/demo/core/admin.py
ADDED