django-markdown-view 0.0.6b2__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.
- django_markdown_view-0.0.6b2.dist-info/METADATA +312 -0
- django_markdown_view-0.0.6b2.dist-info/RECORD +37 -0
- django_markdown_view-0.0.6b2.dist-info/WHEEL +5 -0
- django_markdown_view-0.0.6b2.dist-info/licenses/LICENSE +27 -0
- django_markdown_view-0.0.6b2.dist-info/top_level.txt +1 -0
- markdown_view/__init__.py +1 -0
- markdown_view/apps.py +12 -0
- markdown_view/checks.py +42 -0
- markdown_view/constants.py +11 -0
- markdown_view/loaders.py +61 -0
- markdown_view/markdown_extensions.py +19 -0
- markdown_view/models.py +0 -0
- markdown_view/registry.py +343 -0
- markdown_view/templates/markdown_view/markdown.html +28 -0
- markdown_view/tests/__init__.py +0 -0
- markdown_view/tests/fixtures/loaders_a/readme.md +4 -0
- markdown_view/tests/fixtures/loaders_b/readme.md +4 -0
- markdown_view/tests/settings/__init__.py +0 -0
- markdown_view/tests/settings/coveralls_settings.py +74 -0
- markdown_view/tests/test_checks.py +38 -0
- markdown_view/tests/test_loaders.py +68 -0
- markdown_view/tests/test_markdown_extensions.py +36 -0
- markdown_view/tests/test_registry.py +337 -0
- markdown_view/tests/test_settings_validation.py +55 -0
- markdown_view/tests/test_views.py +169 -0
- markdown_view/tests/testapp/CONTEXT_TEST.md +3 -0
- markdown_view/tests/testapp/LOGGED_IN.md +3 -0
- markdown_view/tests/testapp/NESTED.md +5 -0
- markdown_view/tests/testapp/NO_HEADERS.md +4 -0
- markdown_view/tests/testapp/README.md +35 -0
- markdown_view/tests/testapp/STAFF.md +3 -0
- markdown_view/tests/testapp/UNROUTED.md +4 -0
- markdown_view/tests/testapp/__init__.py +0 -0
- markdown_view/tests/testapp/apps.py +6 -0
- markdown_view/tests/testapp/docs/OTHER.md +8 -0
- markdown_view/tests/urls.py +109 -0
- markdown_view/views.py +109 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Registry mapping ``MarkdownView`` (and subclass) ``file_name`` values to the
|
|
3
|
+
concrete URLs that serve them.
|
|
4
|
+
|
|
5
|
+
Markdown source files commonly link to other markdown files using plain,
|
|
6
|
+
filesystem-relative links (e.g. ``[See also](../README-cron.md)``), exactly
|
|
7
|
+
as they would on GitHub or in a local editor. When such a file is rendered
|
|
8
|
+
as an HTML page instead of viewed as a raw file, that relative link no
|
|
9
|
+
longer resolves to anything meaningful in the browser -- the rendered page
|
|
10
|
+
lives at whatever URL its own view was routed to, not at the file's
|
|
11
|
+
location on disk.
|
|
12
|
+
|
|
13
|
+
This module lets ``MarkdownView`` automatically discover, for any given
|
|
14
|
+
target ``.md`` file, whether some URL pattern in the project serves that
|
|
15
|
+
file via a ``MarkdownView`` (or subclass), and if so, resolve to that URL
|
|
16
|
+
instead of leaving the raw relative path in place.
|
|
17
|
+
|
|
18
|
+
This is intentionally best-effort: routes behind a required URL argument,
|
|
19
|
+
or wrapped by a decorator that doesn't preserve the underlying view's
|
|
20
|
+
``view_class``/``view_initkwargs`` attributes, are silently skipped. A link
|
|
21
|
+
that can't be resolved to a known route is left exactly as markdown would
|
|
22
|
+
otherwise have rendered it, so nothing breaks if a target file simply isn't
|
|
23
|
+
routed anywhere.
|
|
24
|
+
"""
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
29
|
+
|
|
30
|
+
from django.conf import settings
|
|
31
|
+
from django.core.signals import setting_changed
|
|
32
|
+
from django.template import Engine, TemplateDoesNotExist
|
|
33
|
+
from django.urls import NoReverseMatch, URLResolver, get_resolver, reverse
|
|
34
|
+
|
|
35
|
+
from markdown_view.constants import DEFAULT_MARKDOWN_VIEW_LOADERS
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# Cached {id(resolver): {absolute file path: full reverse name}} registries.
|
|
40
|
+
# Keyed by the resolver's identity (Django itself caches `get_resolver()` per
|
|
41
|
+
# urlconf, so this naturally rebuilds if `ROOT_URLCONF`/the active urlconf
|
|
42
|
+
# ever changes, e.g. under `override_settings` in tests). Note that the
|
|
43
|
+
# *reverse name* is cached here, not the result of calling `reverse()` on
|
|
44
|
+
# it -- `reverse()` can depend on request/runtime context (e.g. the active
|
|
45
|
+
# language under `i18n_patterns()`, or the active script prefix), so it must
|
|
46
|
+
# be called fresh every time a link is actually rewritten.
|
|
47
|
+
#
|
|
48
|
+
# This cache key does *not* otherwise account for `MARKDOWN_VIEW_LOADERS`,
|
|
49
|
+
# since a route's file resolution normally never changes while its
|
|
50
|
+
# resolver/urlconf stays the same -- so instead we listen for
|
|
51
|
+
# `MARKDOWN_VIEW_LOADERS` changing (e.g. via `override_settings` in tests)
|
|
52
|
+
# below and explicitly clear the cache when it does.
|
|
53
|
+
_registry_cache = {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _on_setting_changed(*, setting, **kwargs):
|
|
57
|
+
"""
|
|
58
|
+
Invalidate the cached URL registry whenever `MARKDOWN_VIEW_LOADERS`
|
|
59
|
+
changes (e.g. via `override_settings` in tests), since the registry's
|
|
60
|
+
`source_path` keys depend on how that setting resolves `file_name`
|
|
61
|
+
values to files on disk, and merely rebuilding on urlconf/resolver
|
|
62
|
+
identity change (as `get_markdown_view_url_registry()` does) wouldn't
|
|
63
|
+
otherwise catch a loader-only change.
|
|
64
|
+
"""
|
|
65
|
+
if setting == "MARKDOWN_VIEW_LOADERS":
|
|
66
|
+
clear_markdown_view_url_registry_cache()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
setting_changed.connect(_on_setting_changed)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _build_engine():
|
|
73
|
+
"""
|
|
74
|
+
Return a new `Engine` configured with the same template loaders
|
|
75
|
+
`MarkdownView` itself uses, so relative `file_name` values resolve
|
|
76
|
+
identically here and at render time. Built fresh (not cached at module
|
|
77
|
+
scope) so it always reflects the current `MARKDOWN_VIEW_LOADERS` setting,
|
|
78
|
+
e.g. under `override_settings` in tests.
|
|
79
|
+
"""
|
|
80
|
+
return Engine(
|
|
81
|
+
loaders=getattr(
|
|
82
|
+
settings, "MARKDOWN_VIEW_LOADERS", DEFAULT_MARKDOWN_VIEW_LOADERS
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def resolve_markdown_source_path(file_name, engine=None):
|
|
88
|
+
"""
|
|
89
|
+
Resolve a markdown_view `file_name` to the absolute filesystem path of
|
|
90
|
+
its underlying source file, using the same template-loader resolution
|
|
91
|
+
`MarkdownView` uses to actually load and render it.
|
|
92
|
+
|
|
93
|
+
:param file_name: A `file_name` value, as passed to
|
|
94
|
+
`MarkdownView.as_view(file_name=...)`.
|
|
95
|
+
:param engine: An optional `django.template.Engine` to resolve
|
|
96
|
+
`file_name` with. Built via `_build_engine()` if not provided.
|
|
97
|
+
:return: An absolute, normalized filesystem path, or `None` if it
|
|
98
|
+
cannot be resolved (e.g. no matching file exists).
|
|
99
|
+
"""
|
|
100
|
+
if engine is None:
|
|
101
|
+
engine = _build_engine()
|
|
102
|
+
try:
|
|
103
|
+
template = engine.get_template(file_name)
|
|
104
|
+
except TemplateDoesNotExist:
|
|
105
|
+
return None
|
|
106
|
+
return os.path.normpath(template.origin.name)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _iter_markdown_view_routes(resolver, namespace_parts=()):
|
|
110
|
+
"""
|
|
111
|
+
Recursively walk a URLResolver's patterns, yielding
|
|
112
|
+
`(file_name, full_reverse_name)` for every leaf pattern whose view is a
|
|
113
|
+
`MarkdownView` (or subclass) constructed with a `file_name` kwarg.
|
|
114
|
+
|
|
115
|
+
:param resolver: A `django.urls.URLResolver` (typically the project's
|
|
116
|
+
root resolver, from `django.urls.get_resolver()`).
|
|
117
|
+
:param namespace_parts: Namespace segments accumulated from ancestor
|
|
118
|
+
`include()` calls, used to build the fully-qualified reverse name.
|
|
119
|
+
"""
|
|
120
|
+
# Local import to avoid a hard import-time dependency between this
|
|
121
|
+
# module and `views.py` (which doesn't need to import this module at
|
|
122
|
+
# module load time).
|
|
123
|
+
from markdown_view.views import MarkdownView
|
|
124
|
+
|
|
125
|
+
for pattern in resolver.url_patterns:
|
|
126
|
+
if isinstance(pattern, URLResolver):
|
|
127
|
+
# A nested `include()` -- recurse into it, extending the
|
|
128
|
+
# namespace path if this include() was itself namespaced.
|
|
129
|
+
child_namespace = namespace_parts
|
|
130
|
+
if pattern.namespace:
|
|
131
|
+
child_namespace = namespace_parts + (pattern.namespace,)
|
|
132
|
+
yield from _iter_markdown_view_routes(pattern, child_namespace)
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
# Leaf pattern (an actual URL, not a nested urlconf). Only
|
|
136
|
+
# `as_view()`-based class-based views expose `view_class`/
|
|
137
|
+
# `view_initkwargs` on their callback -- function views and
|
|
138
|
+
# views wrapped by decorators that don't forward these
|
|
139
|
+
# attributes will simply have `view_class=None` and get skipped
|
|
140
|
+
# below.
|
|
141
|
+
callback = getattr(pattern, "callback", None)
|
|
142
|
+
view_class = getattr(callback, "view_class", None)
|
|
143
|
+
if not (isinstance(view_class, type) and issubclass(view_class, MarkdownView)):
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
file_name = getattr(callback, "view_initkwargs", {}).get("file_name")
|
|
147
|
+
if not file_name or not pattern.name:
|
|
148
|
+
# No `file_name`, or an unnamed route we can't `reverse()`.
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
# Build the fully-qualified name (e.g. "users:support:readme")
|
|
152
|
+
# needed to `reverse()` this route later, since `pattern.name`
|
|
153
|
+
# alone is only unique within its own urlconf.
|
|
154
|
+
full_name = ":".join((*namespace_parts, pattern.name))
|
|
155
|
+
yield file_name, full_name
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def build_markdown_view_url_registry(resolver=None):
|
|
159
|
+
"""
|
|
160
|
+
Build a `{absolute source file path: full reverse name}` mapping for
|
|
161
|
+
every registered `MarkdownView` (or subclass) route in the project, by
|
|
162
|
+
walking the resolved root URLconf.
|
|
163
|
+
|
|
164
|
+
Note that this stores the *reverse name* for each route, not the result
|
|
165
|
+
of calling `reverse()` on it -- `reverse()`'s result can depend on
|
|
166
|
+
request/runtime context (e.g. the active language under
|
|
167
|
+
`i18n_patterns()`, or the active script prefix), so it must be called
|
|
168
|
+
fresh at the point a link is actually rewritten, not cached here.
|
|
169
|
+
|
|
170
|
+
:param resolver: A `django.urls.URLResolver` to walk. Defaults to
|
|
171
|
+
`django.urls.get_resolver()` (the project's root resolver).
|
|
172
|
+
:return: dict mapping each route's absolute source file path to the
|
|
173
|
+
full reverse name (e.g. `"users:support:readme"`) that serves it.
|
|
174
|
+
"""
|
|
175
|
+
if resolver is None:
|
|
176
|
+
resolver = get_resolver()
|
|
177
|
+
|
|
178
|
+
engine = _build_engine()
|
|
179
|
+
registry = {}
|
|
180
|
+
for file_name, full_name in _iter_markdown_view_routes(resolver):
|
|
181
|
+
# Map the route to the file it serves by resolving `file_name`
|
|
182
|
+
# (e.g. "users/README.md") through the same loader MarkdownView
|
|
183
|
+
# itself uses, so the key here matches the `source_path` that
|
|
184
|
+
# `rewrite_markdown_links()` will later look up against it.
|
|
185
|
+
source_path = resolve_markdown_source_path(file_name, engine=engine)
|
|
186
|
+
if source_path is None:
|
|
187
|
+
logger.debug(
|
|
188
|
+
"markdown_view: route %r references file_name %r, which "
|
|
189
|
+
"could not be resolved to a source file; skipping.",
|
|
190
|
+
full_name, file_name,
|
|
191
|
+
)
|
|
192
|
+
continue
|
|
193
|
+
try:
|
|
194
|
+
# Routes behind a required URL argument (e.g.
|
|
195
|
+
# "<int:pk>/readme/") can't be reversed without knowing that
|
|
196
|
+
# argument's value, so they're simply not linkable targets.
|
|
197
|
+
# The result is only used here to confirm reversibility --
|
|
198
|
+
# it's discarded rather than cached, since `reverse()` must be
|
|
199
|
+
# called fresh (with the active request's language/script
|
|
200
|
+
# prefix, etc.) whenever a link is actually rewritten.
|
|
201
|
+
reverse(full_name)
|
|
202
|
+
except NoReverseMatch:
|
|
203
|
+
logger.debug(
|
|
204
|
+
"markdown_view: could not reverse %r for file_name %r; "
|
|
205
|
+
"skipping (likely behind a required URL argument).",
|
|
206
|
+
full_name, file_name,
|
|
207
|
+
)
|
|
208
|
+
continue
|
|
209
|
+
if source_path in registry:
|
|
210
|
+
# The same source file is served by more than one route. There's
|
|
211
|
+
# no inherently correct answer here, so we make the choice
|
|
212
|
+
# explicit: the first route encountered wins, and later aliases
|
|
213
|
+
# are logged rather than silently overriding it -- adding a
|
|
214
|
+
# later alias shouldn't unexpectedly change all of a file's
|
|
215
|
+
# internal links to the new alias.
|
|
216
|
+
logger.debug(
|
|
217
|
+
"markdown_view: file_name %r is already routed to %r; "
|
|
218
|
+
"ignoring additional route %r for the same file.",
|
|
219
|
+
file_name, registry[source_path], full_name,
|
|
220
|
+
)
|
|
221
|
+
continue
|
|
222
|
+
registry[source_path] = full_name
|
|
223
|
+
return registry
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def get_markdown_view_url_registry(force_refresh=False):
|
|
227
|
+
"""
|
|
228
|
+
Return the (cached) `{absolute source file path: full reverse name}`
|
|
229
|
+
registry for the currently active URLconf, building it on first access.
|
|
230
|
+
|
|
231
|
+
:param force_refresh: Rebuild the registry even if a cached copy exists.
|
|
232
|
+
:return: dict mapping each route's absolute source file path to the
|
|
233
|
+
full reverse name that serves it.
|
|
234
|
+
"""
|
|
235
|
+
resolver = get_resolver()
|
|
236
|
+
# `get_resolver()` is itself cached per-urlconf by Django, so keying
|
|
237
|
+
# on its identity means this registry naturally rebuilds if the
|
|
238
|
+
# active urlconf ever changes (e.g. `ROOT_URLCONF` swapped via
|
|
239
|
+
# `override_settings` in tests) without needing to track that
|
|
240
|
+
# explicitly.
|
|
241
|
+
cache_key = id(resolver)
|
|
242
|
+
if force_refresh or cache_key not in _registry_cache:
|
|
243
|
+
_registry_cache[cache_key] = build_markdown_view_url_registry(resolver)
|
|
244
|
+
return _registry_cache[cache_key]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def clear_markdown_view_url_registry_cache():
|
|
248
|
+
"""Clear all cached URL registries. Primarily useful in tests."""
|
|
249
|
+
_registry_cache.clear()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# Matches an `<a href="...">` opening tag, capturing the text before the
|
|
253
|
+
# href value (group 1), the href value itself (group 2), and the closing
|
|
254
|
+
# quote (group 3), so `_replace()` below can substitute just the middle
|
|
255
|
+
# group back into the original tag without disturbing anything else on
|
|
256
|
+
# the `<a>` tag (other attributes, casing, etc.).
|
|
257
|
+
_HREF_RE = re.compile(r'(<a\b[^>]*\bhref=")([^"]*)(")', re.IGNORECASE)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def rewrite_markdown_links(html, source_path, registry=None):
|
|
261
|
+
"""
|
|
262
|
+
Rewrite `<a href="...">` targets in rendered markdown HTML that point
|
|
263
|
+
at other markdown source files -- via a plain, filesystem-relative
|
|
264
|
+
link resolved relative to `source_path`'s directory -- to the actual
|
|
265
|
+
routed URL of the `MarkdownView` serving that file, if one is
|
|
266
|
+
registered.
|
|
267
|
+
|
|
268
|
+
Links that are absolute (have a scheme or network location, or start
|
|
269
|
+
with `/`) or don't target a `.md` file are left untouched, as are
|
|
270
|
+
relative `.md` links with no matching registered route (they render
|
|
271
|
+
exactly as markdown would otherwise produce). Any query string or
|
|
272
|
+
`#fragment` on the original link is preserved on the rewritten URL.
|
|
273
|
+
|
|
274
|
+
:param html: Rendered HTML markup, already converted from Markdown.
|
|
275
|
+
:param source_path: Absolute filesystem path of the `.md` file `html`
|
|
276
|
+
was rendered from; used to resolve relative link targets.
|
|
277
|
+
:param registry: An optional pre-built `{absolute path: full reverse
|
|
278
|
+
name}` registry. Built via `get_markdown_view_url_registry()` if
|
|
279
|
+
not provided.
|
|
280
|
+
:return: The HTML with resolvable internal links rewritten.
|
|
281
|
+
"""
|
|
282
|
+
if registry is None:
|
|
283
|
+
registry = get_markdown_view_url_registry()
|
|
284
|
+
# Relative links in the source `.md` file are resolved the same way a
|
|
285
|
+
# browser or file manager would: relative to the directory the *source
|
|
286
|
+
# file* lives in, not relative to the URL the rendered page happens to
|
|
287
|
+
# be served at.
|
|
288
|
+
source_dir = os.path.dirname(source_path)
|
|
289
|
+
|
|
290
|
+
def _replace(match):
|
|
291
|
+
prefix, href, suffix = match.groups()
|
|
292
|
+
# Use real URL parsing rather than string tests, so query strings
|
|
293
|
+
# and fragments on otherwise-relative `.md` links (e.g.
|
|
294
|
+
# `guide.md?format=print#section`) are recognized and preserved,
|
|
295
|
+
# while links with a scheme (`mailto:`, `tel:`, `custom-scheme:`,
|
|
296
|
+
# `https://...`) or netloc, or that are site-root-relative, are
|
|
297
|
+
# correctly left alone.
|
|
298
|
+
parts = urlsplit(href)
|
|
299
|
+
if parts.scheme or parts.netloc or parts.path.startswith("/"):
|
|
300
|
+
# Already absolute -- nothing for us to resolve, leave as-is.
|
|
301
|
+
return match.group(0)
|
|
302
|
+
if not parts.path.lower().endswith(".md"):
|
|
303
|
+
# Not a link to another markdown source file (e.g. an image,
|
|
304
|
+
# a `.txt` file, an in-page anchor) -- leave as-is.
|
|
305
|
+
return match.group(0)
|
|
306
|
+
|
|
307
|
+
# Resolve the relative path exactly as the browser would if this
|
|
308
|
+
# were a raw file link, then see if some route serves that exact
|
|
309
|
+
# file.
|
|
310
|
+
resolved_path = os.path.normpath(os.path.join(source_dir, parts.path))
|
|
311
|
+
full_name = registry.get(resolved_path)
|
|
312
|
+
if full_name is None:
|
|
313
|
+
# Points at a real .md file, but nothing routes it -- leave
|
|
314
|
+
# the link exactly as Markdown would otherwise have rendered
|
|
315
|
+
# it, rather than producing a broken URL of our own.
|
|
316
|
+
logger.debug(
|
|
317
|
+
"markdown_view: no registered route for linked file %r "
|
|
318
|
+
"(resolved from %r while rendering %r).",
|
|
319
|
+
resolved_path, href, source_path,
|
|
320
|
+
)
|
|
321
|
+
return match.group(0)
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
# Resolved fresh on every call (rather than cached), since
|
|
325
|
+
# `reverse()`'s result can depend on request/runtime context --
|
|
326
|
+
# e.g. the currently active language under `i18n_patterns()`,
|
|
327
|
+
# or the active script prefix -- not just the URLconf.
|
|
328
|
+
url = reverse(full_name)
|
|
329
|
+
except NoReverseMatch:
|
|
330
|
+
# The registry is stale relative to the active URLconf (e.g. it
|
|
331
|
+
# changed since the registry was built) -- leave as-is rather
|
|
332
|
+
# than producing a broken link.
|
|
333
|
+
logger.debug(
|
|
334
|
+
"markdown_view: could not reverse %r for linked file %r "
|
|
335
|
+
"while rendering %r; leaving link unrewritten.",
|
|
336
|
+
full_name, resolved_path, source_path,
|
|
337
|
+
)
|
|
338
|
+
return match.group(0)
|
|
339
|
+
|
|
340
|
+
new_href = urlunsplit(("", "", url, parts.query, parts.fragment))
|
|
341
|
+
return f"{prefix}{new_href}{suffix}"
|
|
342
|
+
|
|
343
|
+
return _HREF_RE.sub(_replace, html)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{% if use_highlight_js %}
|
|
2
|
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.1.1/styles/a11y-light.min.css" integrity="sha512-PW96n2amVglidqEDLPUdjJ0zByhT20poSqWJYZRutR6CP2QH58k96WmorqNnC4QXnosNeqMJM8FR/93isIifDQ==" crossorigin="anonymous" />
|
|
3
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.1.1/highlight.min.js" integrity="sha512-lnOllyZZlRk/gQIyjS3+h+LUy54uyM4aGq2zbGc82KTvBlp/fodSpdh/pywPztpU9zUqHcJr+jP+a1zLa9oCJw==" crossorigin="anonymous"></script>
|
|
4
|
+
{% endif %}
|
|
5
|
+
|
|
6
|
+
{% comment %}{{ page_title|safe }}{% endcomment %}
|
|
7
|
+
|
|
8
|
+
{% if use_toc %}
|
|
9
|
+
<section id="markdown_view_toc">
|
|
10
|
+
<h3>Table of Contents</h3>
|
|
11
|
+
{{ markdown_toc }}
|
|
12
|
+
</section>
|
|
13
|
+
{% endif %}
|
|
14
|
+
|
|
15
|
+
<section id="markdown_view_content">
|
|
16
|
+
{{ markdown_content }}
|
|
17
|
+
</section>
|
|
18
|
+
|
|
19
|
+
{% if use_highlight_js %}
|
|
20
|
+
<script>
|
|
21
|
+
hljs.initHighlightingOnLoad();
|
|
22
|
+
$(document).ready(function(){
|
|
23
|
+
$('.md-content div.toc ul').attr("class", "cta-links");
|
|
24
|
+
$('.md-content div.toc ul li a').attr("class", "default-fa");
|
|
25
|
+
$('.md-content table').attr("class", "table table-bordered table-hover table-striped table-mobile tablesorter tablesorter-default");
|
|
26
|
+
});
|
|
27
|
+
</script>
|
|
28
|
+
{% endif %}
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django settings used to run this project's test suite (see ``manage.py`` and
|
|
3
|
+
``tox.ini``, which invoke ``manage.py test`` / ``coverage run manage.py
|
|
4
|
+
test`` against
|
|
5
|
+
``DJANGO_SETTINGS_MODULE=markdown_view.tests.settings.coveralls_settings``).
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
# The `markdown_view/tests/` directory, used below to locate fixtures.
|
|
10
|
+
TESTS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
11
|
+
|
|
12
|
+
# The repository root. `markdown_view.loaders.MarkdownLoader` falls back to
|
|
13
|
+
# this (via `MarkdownLoader.get_dirs()`) when `MARKDOWN_VIEW_BASE_DIR` isn't
|
|
14
|
+
# set, so this is also exercised directly by `test_loaders.py`.
|
|
15
|
+
BASE_DIR = os.path.dirname(os.path.dirname(TESTS_DIR))
|
|
16
|
+
|
|
17
|
+
SECRET_KEY = "not-a-secret-only-used-to-run-tests"
|
|
18
|
+
DEBUG = True
|
|
19
|
+
ALLOWED_HOSTS = ["*"]
|
|
20
|
+
|
|
21
|
+
INSTALLED_APPS = [
|
|
22
|
+
"django.contrib.auth",
|
|
23
|
+
"django.contrib.contenttypes",
|
|
24
|
+
"django.contrib.sessions",
|
|
25
|
+
"django.contrib.staticfiles",
|
|
26
|
+
"markdown_view",
|
|
27
|
+
"markdown_view.tests.testapp",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
MIDDLEWARE = [
|
|
31
|
+
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
32
|
+
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
33
|
+
"django.middleware.common.CommonMiddleware",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
DATABASES = {
|
|
37
|
+
"default": {
|
|
38
|
+
"ENGINE": "django.db.backends.sqlite3",
|
|
39
|
+
"NAME": ":memory:",
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
ROOT_URLCONF = "markdown_view.tests.urls"
|
|
44
|
+
|
|
45
|
+
TEMPLATES = [
|
|
46
|
+
{
|
|
47
|
+
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
48
|
+
"DIRS": [],
|
|
49
|
+
"APP_DIRS": True,
|
|
50
|
+
"OPTIONS": {
|
|
51
|
+
"context_processors": [
|
|
52
|
+
"django.template.context_processors.request",
|
|
53
|
+
"django.contrib.auth.context_processors.auth",
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
# The default `MARKDOWN_VIEW_LOADERS` (`markdown_view.loaders.MarkdownLoader`)
|
|
60
|
+
# is used for the vast majority of the suite -- it discovers fixtures under
|
|
61
|
+
# `markdown_view/tests/testapp/` via `INSTALLED_APPS`, exactly as it would in
|
|
62
|
+
# a real project. `markdown_view.tests.test_registry`'s loader-cache
|
|
63
|
+
# invalidation tests override this per-test (via `override_settings`) to
|
|
64
|
+
# point at `fixtures/loaders_a`/`fixtures/loaders_b` instead.
|
|
65
|
+
|
|
66
|
+
AUTH_PASSWORD_VALIDATORS = []
|
|
67
|
+
|
|
68
|
+
LOGIN_URL = "/accounts/login/"
|
|
69
|
+
|
|
70
|
+
STATIC_URL = "/static/"
|
|
71
|
+
|
|
72
|
+
USE_TZ = True
|
|
73
|
+
|
|
74
|
+
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from django.core.checks import Tags, run_checks
|
|
2
|
+
from django.test import SimpleTestCase, override_settings
|
|
3
|
+
|
|
4
|
+
from markdown_view.checks import markdown_view_check
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MarkdownViewCheckTests(SimpleTestCase):
|
|
8
|
+
def test_returns_empty_list_for_valid_settings(self):
|
|
9
|
+
# markdown_view/tests/settings/coveralls_settings.py doesn't define
|
|
10
|
+
# any of the optional MARKDOWN_VIEW_* settings, so there's nothing
|
|
11
|
+
# to flag.
|
|
12
|
+
self.assertEqual(markdown_view_check(app_configs=[]), [])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MarkdownViewCheckRegistrationTests(SimpleTestCase):
|
|
16
|
+
"""
|
|
17
|
+
Confirms `markdown_view_check` is actually *wired up* through Django's
|
|
18
|
+
system-check framework (registered against `Tags.security` in
|
|
19
|
+
`MarkdownViewConfig.ready()`, see `markdown_view/apps.py`), not just
|
|
20
|
+
that the function itself behaves correctly in isolation.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def test_run_checks_surfaces_invalid_setting_via_registered_check(self):
|
|
24
|
+
with override_settings(MARKDOWN_VIEW_TEMPLATE_USE_TOC="not-a-bool"):
|
|
25
|
+
errors = run_checks(tags=[Tags.security])
|
|
26
|
+
|
|
27
|
+
self.assertTrue(
|
|
28
|
+
any(error.id == "markdown_view.E001" for error in errors),
|
|
29
|
+
"Expected django.core.checks.run_checks() to surface "
|
|
30
|
+
"markdown_view.E001 for an invalid MARKDOWN_VIEW_* setting; "
|
|
31
|
+
"got: %r" % (errors,),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def test_run_checks_reports_no_markdown_view_errors_for_valid_settings(self):
|
|
35
|
+
errors = run_checks(tags=[Tags.security])
|
|
36
|
+
self.assertFalse(
|
|
37
|
+
any(error.id == "markdown_view.E001" for error in errors)
|
|
38
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from django.template import Engine
|
|
4
|
+
from django.test import SimpleTestCase, override_settings
|
|
5
|
+
|
|
6
|
+
from markdown_view.loaders import MarkdownLoader
|
|
7
|
+
|
|
8
|
+
TESTAPP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "testapp")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_loader():
|
|
12
|
+
engine = Engine(loaders=["markdown_view.loaders.MarkdownLoader"])
|
|
13
|
+
return engine.template_loaders[0]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MarkdownLoaderGetDirsTests(SimpleTestCase):
|
|
17
|
+
def test_includes_app_template_dirs(self):
|
|
18
|
+
loader = _get_loader()
|
|
19
|
+
dirs = [str(d) for d in loader.get_dirs()]
|
|
20
|
+
self.assertIn(TESTAPP_DIR, dirs)
|
|
21
|
+
|
|
22
|
+
@override_settings(MARKDOWN_VIEW_BASE_DIR="/tmp/some-markdown-base-dir")
|
|
23
|
+
def test_includes_markdown_view_base_dir_when_set(self):
|
|
24
|
+
loader = _get_loader()
|
|
25
|
+
dirs = [str(d) for d in loader.get_dirs()]
|
|
26
|
+
self.assertIn("/tmp/some-markdown-base-dir", dirs)
|
|
27
|
+
|
|
28
|
+
def test_includes_base_dir_when_markdown_view_base_dir_not_set(self):
|
|
29
|
+
# markdown_view/tests/settings.py sets BASE_DIR but not
|
|
30
|
+
# MARKDOWN_VIEW_BASE_DIR, so BASE_DIR should be used as a fallback.
|
|
31
|
+
from django.conf import settings
|
|
32
|
+
|
|
33
|
+
loader = _get_loader()
|
|
34
|
+
dirs = [str(d) for d in loader.get_dirs()]
|
|
35
|
+
self.assertIn(settings.BASE_DIR, dirs)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MarkdownLoaderGetTemplateSourcesTests(SimpleTestCase):
|
|
39
|
+
def test_yields_one_origin_for_valid_name(self):
|
|
40
|
+
loader = _get_loader()
|
|
41
|
+
origins = list(loader.get_template_sources("testapp/README.md"))
|
|
42
|
+
self.assertEqual(len(origins), 1)
|
|
43
|
+
self.assertEqual(
|
|
44
|
+
os.path.normpath(origins[0].name),
|
|
45
|
+
os.path.join(TESTAPP_DIR, "README.md"),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def test_yields_one_origin_for_nested_valid_name(self):
|
|
49
|
+
loader = _get_loader()
|
|
50
|
+
origins = list(loader.get_template_sources("testapp/docs/OTHER.md"))
|
|
51
|
+
self.assertEqual(len(origins), 1)
|
|
52
|
+
self.assertEqual(
|
|
53
|
+
os.path.normpath(origins[0].name),
|
|
54
|
+
os.path.join(TESTAPP_DIR, "docs", "OTHER.md"),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def test_yields_nothing_for_non_md_name(self):
|
|
58
|
+
loader = _get_loader()
|
|
59
|
+
origins = list(loader.get_template_sources("testapp/notes.txt"))
|
|
60
|
+
self.assertEqual(origins, [])
|
|
61
|
+
|
|
62
|
+
def test_does_not_raise_for_path_escaping_template_dir(self):
|
|
63
|
+
loader = _get_loader()
|
|
64
|
+
# Attempts to escape testapp/'s directory via `..`; the
|
|
65
|
+
# SuspiciousFileOperation guard should be swallowed, yielding
|
|
66
|
+
# nothing rather than raising.
|
|
67
|
+
origins = list(loader.get_template_sources("testapp/../secret.md"))
|
|
68
|
+
self.assertEqual(origins, [])
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from xml.etree import ElementTree
|
|
2
|
+
|
|
3
|
+
import markdown
|
|
4
|
+
from django.test import SimpleTestCase
|
|
5
|
+
|
|
6
|
+
from markdown_view.markdown_extensions import ImageExtension, InlineImageProcessor
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InlineImageProcessorTests(SimpleTestCase):
|
|
10
|
+
def _run(self, src):
|
|
11
|
+
root = ElementTree.Element("div")
|
|
12
|
+
img = ElementTree.SubElement(root, "img")
|
|
13
|
+
img.set("src", src)
|
|
14
|
+
InlineImageProcessor(md=None).run(root)
|
|
15
|
+
return root.find("img")
|
|
16
|
+
|
|
17
|
+
def test_rewrites_src_to_static_tag(self):
|
|
18
|
+
img = self._run("foo.png")
|
|
19
|
+
self.assertEqual(img.attrib["src"], "{% static 'foo.png' %}")
|
|
20
|
+
|
|
21
|
+
def test_strips_leading_static_segment(self):
|
|
22
|
+
img = self._run("/static/images/foo.png")
|
|
23
|
+
self.assertEqual(img.attrib["src"], "{% static 'images/foo.png' %}")
|
|
24
|
+
|
|
25
|
+
def test_sets_img_fluid_class(self):
|
|
26
|
+
img = self._run("foo.png")
|
|
27
|
+
self.assertEqual(img.attrib["class"], "img-fluid")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ImageExtensionTests(SimpleTestCase):
|
|
31
|
+
def test_extends_markdown_conversion(self):
|
|
32
|
+
md = markdown.Markdown(extensions=[ImageExtension()])
|
|
33
|
+
html = md.convert("")
|
|
34
|
+
self.assertIn("{% static 'foo.png' %}", html)
|
|
35
|
+
self.assertIn('class="img-fluid"', html)
|
|
36
|
+
self.assertIn('alt="alt text"', html)
|