django-markdown-view 0.0.6__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.6.dist-info/METADATA +371 -0
- django_markdown_view-0.0.6.dist-info/RECORD +42 -0
- django_markdown_view-0.0.6.dist-info/WHEEL +5 -0
- django_markdown_view-0.0.6.dist-info/licenses/LICENSE +27 -0
- django_markdown_view-0.0.6.dist-info/top_level.txt +1 -0
- markdown_view/__init__.py +1 -0
- markdown_view/apps.py +12 -0
- markdown_view/checks.py +46 -0
- markdown_view/constants.py +13 -0
- markdown_view/escaping.py +82 -0
- markdown_view/loaders.py +61 -0
- markdown_view/loading.py +57 -0
- markdown_view/markdown_extensions.py +19 -0
- markdown_view/models.py +0 -0
- markdown_view/registry.py +387 -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_escaping.py +75 -0
- markdown_view/tests/test_loaders.py +68 -0
- markdown_view/tests/test_loading.py +37 -0
- markdown_view/tests/test_markdown_extensions.py +36 -0
- markdown_view/tests/test_registry.py +467 -0
- markdown_view/tests/test_settings_validation.py +76 -0
- markdown_view/tests/test_views.py +280 -0
- markdown_view/tests/testapp/CONTEXT_TEST.md +3 -0
- markdown_view/tests/testapp/ESCAPING_TEST.md +14 -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 +118 -0
- markdown_view/views.py +145 -0
markdown_view/loading.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Locate and read a ``markdown_view`` ``file_name`` value's underlying
|
|
3
|
+
source file without compiling its raw, pre-Markdown-conversion contents
|
|
4
|
+
as a Django template.
|
|
5
|
+
|
|
6
|
+
Both `MarkdownView` and `registry.py` need to resolve a `file_name` (e.g.
|
|
7
|
+
``"users/README.md"``) to an actual file on disk, using whatever loaders
|
|
8
|
+
`MARKDOWN_VIEW_LOADERS` configures. The obvious way to do that with
|
|
9
|
+
Django's own `Engine`/`Loader` API is `engine.get_template(file_name)` --
|
|
10
|
+
but `Loader.get_template()` doesn't just locate the file: it also
|
|
11
|
+
constructs a `django.template.base.Template` from its *raw* contents,
|
|
12
|
+
which eagerly compiles and validates that source as Django template
|
|
13
|
+
syntax immediately, before any Markdown conversion has happened. A `.md`
|
|
14
|
+
file whose raw text contains literal template-tag-shaped syntax anywhere
|
|
15
|
+
at all -- inside a fenced code block documenting Django itself, for
|
|
16
|
+
instance, or in an inline-code span -- would then raise
|
|
17
|
+
`TemplateSyntaxError` merely from being *loaded*, well before
|
|
18
|
+
`MarkdownView` gets a chance to convert it to HTML, and therefore before
|
|
19
|
+
`escaping.escape_unsafe_template_syntax()` (which only ever sees the
|
|
20
|
+
already-converted HTML) gets any chance to make that literal syntax safe.
|
|
21
|
+
|
|
22
|
+
`load_markdown_source()` instead walks the engine's configured loaders and
|
|
23
|
+
origins directly, reading each candidate file's raw contents (via each
|
|
24
|
+
loader's own `get_contents()`, the same plain, uncompiled read
|
|
25
|
+
`Loader.get_template()` itself uses before wrapping the result in a
|
|
26
|
+
`Template`) without ever constructing a `Template` from them.
|
|
27
|
+
"""
|
|
28
|
+
import os
|
|
29
|
+
|
|
30
|
+
from django.template import TemplateDoesNotExist
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_markdown_source(file_name, engine):
|
|
34
|
+
"""
|
|
35
|
+
Resolve `file_name` to its underlying source file using `engine`'s
|
|
36
|
+
configured template loaders, and return its raw text -- without
|
|
37
|
+
compiling that text as a Django template.
|
|
38
|
+
|
|
39
|
+
:param file_name: A `file_name` value, as passed to
|
|
40
|
+
`MarkdownView.as_view(file_name=...)`.
|
|
41
|
+
:param engine: The `django.template.Engine` whose loaders should be
|
|
42
|
+
used to resolve `file_name`.
|
|
43
|
+
:return: A `(source_text, source_path)` tuple, where `source_path` is
|
|
44
|
+
the absolute, normalized filesystem path the source was read from.
|
|
45
|
+
:raises TemplateDoesNotExist: If no configured loader can locate
|
|
46
|
+
`file_name`.
|
|
47
|
+
"""
|
|
48
|
+
tried = []
|
|
49
|
+
for loader in engine.template_loaders:
|
|
50
|
+
for origin in loader.get_template_sources(file_name):
|
|
51
|
+
try:
|
|
52
|
+
contents = loader.get_contents(origin)
|
|
53
|
+
except TemplateDoesNotExist:
|
|
54
|
+
tried.append((origin, "Source does not exist"))
|
|
55
|
+
continue
|
|
56
|
+
return contents, os.path.normpath(origin.name)
|
|
57
|
+
raise TemplateDoesNotExist(file_name, tried=tried)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from markdown.treeprocessors import Treeprocessor
|
|
4
|
+
from markdown.extensions import Extension
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class InlineImageProcessor(Treeprocessor):
|
|
8
|
+
def run(self, root):
|
|
9
|
+
for element in root.iter("img"):
|
|
10
|
+
src_orig = element.attrib.get('src', '')
|
|
11
|
+
src_re = re.sub(r"[/\\]*static[/\\]*", "", src_orig)
|
|
12
|
+
src = "{{% static '{}' %}}".format(src_re)
|
|
13
|
+
element.attrib["src"] = src
|
|
14
|
+
element.attrib["class"] = "img-fluid"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ImageExtension(Extension):
|
|
18
|
+
def extendMarkdown(self, md):
|
|
19
|
+
md.treeprocessors.register(InlineImageProcessor(md), 'inlineimageprocessor', 15)
|
markdown_view/models.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,387 @@
|
|
|
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, unless a fallback root is configured via
|
|
23
|
+
``MARKDOWN_VIEW_UNRESOLVED_LINK_ROOT`` (see ``rewrite_markdown_links()``),
|
|
24
|
+
in which case such links -- including relative links to files other than
|
|
25
|
+
``.md``, such as repository-relative source links -- are resolved against
|
|
26
|
+
that root instead.
|
|
27
|
+
"""
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
from urllib.parse import urljoin, urlsplit, urlunsplit
|
|
32
|
+
|
|
33
|
+
from django.conf import settings
|
|
34
|
+
from django.core.signals import setting_changed
|
|
35
|
+
from django.template import Engine, TemplateDoesNotExist
|
|
36
|
+
from django.urls import NoReverseMatch, URLResolver, get_resolver, reverse
|
|
37
|
+
|
|
38
|
+
from markdown_view.constants import (
|
|
39
|
+
DEFAULT_MARKDOWN_VIEW_LOADERS, DEFAULT_MARKDOWN_VIEW_UNRESOLVED_LINK_ROOT,
|
|
40
|
+
)
|
|
41
|
+
from markdown_view.loading import load_markdown_source
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
# Cached {id(resolver): {absolute file path: full reverse name}} registries.
|
|
46
|
+
# Keyed by the resolver's identity (Django itself caches `get_resolver()` per
|
|
47
|
+
# urlconf, so this naturally rebuilds if `ROOT_URLCONF`/the active urlconf
|
|
48
|
+
# ever changes, e.g. under `override_settings` in tests). Note that the
|
|
49
|
+
# *reverse name* is cached here, not the result of calling `reverse()` on
|
|
50
|
+
# it -- `reverse()` can depend on request/runtime context (e.g. the active
|
|
51
|
+
# language under `i18n_patterns()`, or the active script prefix), so it must
|
|
52
|
+
# be called fresh every time a link is actually rewritten.
|
|
53
|
+
#
|
|
54
|
+
# This cache key does *not* otherwise account for `MARKDOWN_VIEW_LOADERS`,
|
|
55
|
+
# since a route's file resolution normally never changes while its
|
|
56
|
+
# resolver/urlconf stays the same -- so instead we listen for
|
|
57
|
+
# `MARKDOWN_VIEW_LOADERS` changing (e.g. via `override_settings` in tests)
|
|
58
|
+
# below and explicitly clear the cache when it does.
|
|
59
|
+
_registry_cache = {}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _on_setting_changed(*, setting, **kwargs):
|
|
63
|
+
"""
|
|
64
|
+
Invalidate the cached URL registry whenever `MARKDOWN_VIEW_LOADERS`
|
|
65
|
+
changes (e.g. via `override_settings` in tests), since the registry's
|
|
66
|
+
`source_path` keys depend on how that setting resolves `file_name`
|
|
67
|
+
values to files on disk, and merely rebuilding on urlconf/resolver
|
|
68
|
+
identity change (as `get_markdown_view_url_registry()` does) wouldn't
|
|
69
|
+
otherwise catch a loader-only change.
|
|
70
|
+
"""
|
|
71
|
+
if setting == "MARKDOWN_VIEW_LOADERS":
|
|
72
|
+
clear_markdown_view_url_registry_cache()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
setting_changed.connect(_on_setting_changed)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _build_engine():
|
|
79
|
+
"""
|
|
80
|
+
Return a new `Engine` configured with the same template loaders
|
|
81
|
+
`MarkdownView` itself uses, so relative `file_name` values resolve
|
|
82
|
+
identically here and at render time. Built fresh (not cached at module
|
|
83
|
+
scope) so it always reflects the current `MARKDOWN_VIEW_LOADERS` setting,
|
|
84
|
+
e.g. under `override_settings` in tests.
|
|
85
|
+
"""
|
|
86
|
+
return Engine(
|
|
87
|
+
loaders=getattr(
|
|
88
|
+
settings, "MARKDOWN_VIEW_LOADERS", DEFAULT_MARKDOWN_VIEW_LOADERS
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def resolve_markdown_source_path(file_name, engine=None):
|
|
94
|
+
"""
|
|
95
|
+
Resolve a markdown_view `file_name` to the absolute filesystem path of
|
|
96
|
+
its underlying source file, using the same template-loader resolution
|
|
97
|
+
`MarkdownView` uses to actually load and render it.
|
|
98
|
+
|
|
99
|
+
Deliberately does not use `engine.get_template(...)`: that constructs
|
|
100
|
+
a `Template` from the file's raw, pre-Markdown-conversion contents,
|
|
101
|
+
which eagerly compiles/validates that raw text as Django template
|
|
102
|
+
syntax and can raise `TemplateSyntaxError` for a `.md` file whose raw
|
|
103
|
+
source happens to contain literal template-tag-shaped text anywhere.
|
|
104
|
+
`load_markdown_source()` resolves the same file without compiling it.
|
|
105
|
+
|
|
106
|
+
:param file_name: A `file_name` value, as passed to
|
|
107
|
+
`MarkdownView.as_view(file_name=...)`.
|
|
108
|
+
:param engine: An optional `django.template.Engine` to resolve
|
|
109
|
+
`file_name` with. Built via `_build_engine()` if not provided.
|
|
110
|
+
:return: An absolute, normalized filesystem path, or `None` if it
|
|
111
|
+
cannot be resolved (e.g. no matching file exists).
|
|
112
|
+
"""
|
|
113
|
+
if engine is None:
|
|
114
|
+
engine = _build_engine()
|
|
115
|
+
try:
|
|
116
|
+
_, source_path = load_markdown_source(file_name, engine)
|
|
117
|
+
except TemplateDoesNotExist:
|
|
118
|
+
return None
|
|
119
|
+
return source_path
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _iter_markdown_view_routes(resolver, namespace_parts=()):
|
|
123
|
+
"""
|
|
124
|
+
Recursively walk a URLResolver's patterns, yielding
|
|
125
|
+
`(file_name, full_reverse_name)` for every leaf pattern whose view is a
|
|
126
|
+
`MarkdownView` (or subclass) constructed with a `file_name` kwarg.
|
|
127
|
+
|
|
128
|
+
:param resolver: A `django.urls.URLResolver` (typically the project's
|
|
129
|
+
root resolver, from `django.urls.get_resolver()`).
|
|
130
|
+
:param namespace_parts: Namespace segments accumulated from ancestor
|
|
131
|
+
`include()` calls, used to build the fully-qualified reverse name.
|
|
132
|
+
"""
|
|
133
|
+
# Local import to avoid a hard import-time dependency between this
|
|
134
|
+
# module and `views.py` (which doesn't need to import this module at
|
|
135
|
+
# module load time).
|
|
136
|
+
from markdown_view.views import MarkdownView
|
|
137
|
+
|
|
138
|
+
for pattern in resolver.url_patterns:
|
|
139
|
+
if isinstance(pattern, URLResolver):
|
|
140
|
+
# A nested `include()` -- recurse into it, extending the
|
|
141
|
+
# namespace path if this include() was itself namespaced.
|
|
142
|
+
child_namespace = namespace_parts
|
|
143
|
+
if pattern.namespace:
|
|
144
|
+
child_namespace = namespace_parts + (pattern.namespace,)
|
|
145
|
+
yield from _iter_markdown_view_routes(pattern, child_namespace)
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
# Leaf pattern (an actual URL, not a nested urlconf). Only
|
|
149
|
+
# `as_view()`-based class-based views expose `view_class`/
|
|
150
|
+
# `view_initkwargs` on their callback -- function views and
|
|
151
|
+
# views wrapped by decorators that don't forward these
|
|
152
|
+
# attributes will simply have `view_class=None` and get skipped
|
|
153
|
+
# below.
|
|
154
|
+
callback = getattr(pattern, "callback", None)
|
|
155
|
+
view_class = getattr(callback, "view_class", None)
|
|
156
|
+
if not (isinstance(view_class, type) and issubclass(view_class, MarkdownView)):
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
file_name = getattr(callback, "view_initkwargs", {}).get("file_name")
|
|
160
|
+
if not file_name or not pattern.name:
|
|
161
|
+
# No `file_name`, or an unnamed route we can't `reverse()`.
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
# Build the fully-qualified name (e.g. "users:support:readme")
|
|
165
|
+
# needed to `reverse()` this route later, since `pattern.name`
|
|
166
|
+
# alone is only unique within its own urlconf.
|
|
167
|
+
full_name = ":".join((*namespace_parts, pattern.name))
|
|
168
|
+
yield file_name, full_name
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def build_markdown_view_url_registry(resolver=None):
|
|
172
|
+
"""
|
|
173
|
+
Build a `{absolute source file path: full reverse name}` mapping for
|
|
174
|
+
every registered `MarkdownView` (or subclass) route in the project, by
|
|
175
|
+
walking the resolved root URLconf.
|
|
176
|
+
|
|
177
|
+
Note that this stores the *reverse name* for each route, not the result
|
|
178
|
+
of calling `reverse()` on it -- `reverse()`'s result can depend on
|
|
179
|
+
request/runtime context (e.g. the active language under
|
|
180
|
+
`i18n_patterns()`, or the active script prefix), so it must be called
|
|
181
|
+
fresh at the point a link is actually rewritten, not cached here.
|
|
182
|
+
|
|
183
|
+
:param resolver: A `django.urls.URLResolver` to walk. Defaults to
|
|
184
|
+
`django.urls.get_resolver()` (the project's root resolver).
|
|
185
|
+
:return: dict mapping each route's absolute source file path to the
|
|
186
|
+
full reverse name (e.g. `"users:support:readme"`) that serves it.
|
|
187
|
+
"""
|
|
188
|
+
if resolver is None:
|
|
189
|
+
resolver = get_resolver()
|
|
190
|
+
|
|
191
|
+
engine = _build_engine()
|
|
192
|
+
registry = {}
|
|
193
|
+
for file_name, full_name in _iter_markdown_view_routes(resolver):
|
|
194
|
+
# Map the route to the file it serves by resolving `file_name`
|
|
195
|
+
# (e.g. "users/README.md") through the same loader MarkdownView
|
|
196
|
+
# itself uses, so the key here matches the `source_path` that
|
|
197
|
+
# `rewrite_markdown_links()` will later look up against it.
|
|
198
|
+
source_path = resolve_markdown_source_path(file_name, engine=engine)
|
|
199
|
+
if source_path is None:
|
|
200
|
+
logger.debug(
|
|
201
|
+
"markdown_view: route %r references file_name %r, which "
|
|
202
|
+
"could not be resolved to a source file; skipping.",
|
|
203
|
+
full_name, file_name,
|
|
204
|
+
)
|
|
205
|
+
continue
|
|
206
|
+
try:
|
|
207
|
+
# Routes behind a required URL argument (e.g.
|
|
208
|
+
# "<int:pk>/readme/") can't be reversed without knowing that
|
|
209
|
+
# argument's value, so they're simply not linkable targets.
|
|
210
|
+
# The result is only used here to confirm reversibility --
|
|
211
|
+
# it's discarded rather than cached, since `reverse()` must be
|
|
212
|
+
# called fresh (with the active request's language/script
|
|
213
|
+
# prefix, etc.) whenever a link is actually rewritten.
|
|
214
|
+
reverse(full_name)
|
|
215
|
+
except NoReverseMatch:
|
|
216
|
+
logger.debug(
|
|
217
|
+
"markdown_view: could not reverse %r for file_name %r; "
|
|
218
|
+
"skipping (likely behind a required URL argument).",
|
|
219
|
+
full_name, file_name,
|
|
220
|
+
)
|
|
221
|
+
continue
|
|
222
|
+
if source_path in registry:
|
|
223
|
+
# The same source file is served by more than one route. There's
|
|
224
|
+
# no inherently correct answer here, so we make the choice
|
|
225
|
+
# explicit: the first route encountered wins, and later aliases
|
|
226
|
+
# are logged rather than silently overriding it -- adding a
|
|
227
|
+
# later alias shouldn't unexpectedly change all of a file's
|
|
228
|
+
# internal links to the new alias.
|
|
229
|
+
logger.debug(
|
|
230
|
+
"markdown_view: file_name %r is already routed to %r; "
|
|
231
|
+
"ignoring additional route %r for the same file.",
|
|
232
|
+
file_name, registry[source_path], full_name,
|
|
233
|
+
)
|
|
234
|
+
continue
|
|
235
|
+
registry[source_path] = full_name
|
|
236
|
+
return registry
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def get_markdown_view_url_registry(force_refresh=False):
|
|
240
|
+
"""
|
|
241
|
+
Return the (cached) `{absolute source file path: full reverse name}`
|
|
242
|
+
registry for the currently active URLconf, building it on first access.
|
|
243
|
+
|
|
244
|
+
:param force_refresh: Rebuild the registry even if a cached copy exists.
|
|
245
|
+
:return: dict mapping each route's absolute source file path to the
|
|
246
|
+
full reverse name that serves it.
|
|
247
|
+
"""
|
|
248
|
+
resolver = get_resolver()
|
|
249
|
+
# `get_resolver()` is itself cached per-urlconf by Django, so keying
|
|
250
|
+
# on its identity means this registry naturally rebuilds if the
|
|
251
|
+
# active urlconf ever changes (e.g. `ROOT_URLCONF` swapped via
|
|
252
|
+
# `override_settings` in tests) without needing to track that
|
|
253
|
+
# explicitly.
|
|
254
|
+
cache_key = id(resolver)
|
|
255
|
+
if force_refresh or cache_key not in _registry_cache:
|
|
256
|
+
_registry_cache[cache_key] = build_markdown_view_url_registry(resolver)
|
|
257
|
+
return _registry_cache[cache_key]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def clear_markdown_view_url_registry_cache():
|
|
261
|
+
"""Clear all cached URL registries. Primarily useful in tests."""
|
|
262
|
+
_registry_cache.clear()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# Matches an `<a href="...">` opening tag, capturing the text before the
|
|
266
|
+
# href value (group 1), the href value itself (group 2), and the closing
|
|
267
|
+
# quote (group 3), so `_replace()` below can substitute just the middle
|
|
268
|
+
# group back into the original tag without disturbing anything else on
|
|
269
|
+
# the `<a>` tag (other attributes, casing, etc.).
|
|
270
|
+
_HREF_RE = re.compile(r'(<a\b[^>]*\bhref=")([^"]*)(")', re.IGNORECASE)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def rewrite_markdown_links(html, source_path, registry=None):
|
|
274
|
+
"""
|
|
275
|
+
Rewrite `<a href="...">` targets in rendered markdown HTML that point
|
|
276
|
+
at other markdown source files -- via a plain, filesystem-relative
|
|
277
|
+
link resolved relative to `source_path`'s directory -- to the actual
|
|
278
|
+
routed URL of the `MarkdownView` serving that file, if one is
|
|
279
|
+
registered.
|
|
280
|
+
|
|
281
|
+
When a relative link doesn't resolve to a registered route (e.g. it
|
|
282
|
+
targets a file that isn't served by any `MarkdownView`, or isn't a
|
|
283
|
+
`.md` file at all -- such as a repository-relative link to source
|
|
284
|
+
code), it's instead resolved against the optional
|
|
285
|
+
`MARKDOWN_VIEW_UNRESOLVED_LINK_ROOT` setting, if configured. This lets
|
|
286
|
+
links like ``[helper](src/example/module.py)`` -- which resolve
|
|
287
|
+
correctly when browsing the source repository, but not when rendered
|
|
288
|
+
as a page served at its own unrelated URL -- point somewhere useful
|
|
289
|
+
(e.g. the same file, viewed on the project's SCM web interface).
|
|
290
|
+
Registered-route resolution always takes precedence over this
|
|
291
|
+
fallback. With no fallback root configured, such links are left
|
|
292
|
+
exactly as markdown would otherwise have rendered them.
|
|
293
|
+
|
|
294
|
+
Links that are absolute (have a scheme or network location, start
|
|
295
|
+
with `/`, or are fragment-only) are always left untouched. Any query
|
|
296
|
+
string or `#fragment` on the original link is preserved on the
|
|
297
|
+
rewritten URL, and `./`/`../` relative-path segments are resolved
|
|
298
|
+
correctly (rather than through simple string concatenation) when
|
|
299
|
+
applying the fallback root.
|
|
300
|
+
|
|
301
|
+
:param html: Rendered HTML markup, already converted from Markdown.
|
|
302
|
+
:param source_path: Absolute filesystem path of the `.md` file `html`
|
|
303
|
+
was rendered from; used to resolve relative link targets.
|
|
304
|
+
:param registry: An optional pre-built `{absolute path: full reverse
|
|
305
|
+
name}` registry. Built via `get_markdown_view_url_registry()` if
|
|
306
|
+
not provided.
|
|
307
|
+
:return: The HTML with resolvable internal links rewritten.
|
|
308
|
+
"""
|
|
309
|
+
if registry is None:
|
|
310
|
+
registry = get_markdown_view_url_registry()
|
|
311
|
+
# Relative links in the source `.md` file are resolved the same way a
|
|
312
|
+
# browser or file manager would: relative to the directory the *source
|
|
313
|
+
# file* lives in, not relative to the URL the rendered page happens to
|
|
314
|
+
# be served at.
|
|
315
|
+
source_dir = os.path.dirname(source_path)
|
|
316
|
+
unresolved_link_root = getattr(
|
|
317
|
+
settings,
|
|
318
|
+
"MARKDOWN_VIEW_UNRESOLVED_LINK_ROOT",
|
|
319
|
+
DEFAULT_MARKDOWN_VIEW_UNRESOLVED_LINK_ROOT,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def _replace(match):
|
|
323
|
+
prefix, href, suffix = match.groups()
|
|
324
|
+
# Use real URL parsing rather than string tests, so query strings
|
|
325
|
+
# and fragments on otherwise-relative links (e.g.
|
|
326
|
+
# `guide.md?format=print#section`) are recognized and preserved,
|
|
327
|
+
# while links with a scheme (`mailto:`, `tel:`, `custom-scheme:`,
|
|
328
|
+
# `https://...`) or netloc, or that are site-root-relative, are
|
|
329
|
+
# correctly left alone.
|
|
330
|
+
parts = urlsplit(href)
|
|
331
|
+
if parts.scheme or parts.netloc or parts.path.startswith("/"):
|
|
332
|
+
# Already absolute -- nothing for us to resolve, leave as-is.
|
|
333
|
+
return match.group(0)
|
|
334
|
+
if not parts.path:
|
|
335
|
+
# Fragment-only (e.g. "#section") or query-only link -- no
|
|
336
|
+
# relative target to resolve at all.
|
|
337
|
+
return match.group(0)
|
|
338
|
+
|
|
339
|
+
resolved_url = None
|
|
340
|
+
if parts.path.lower().endswith(".md"):
|
|
341
|
+
# Resolve the relative path exactly as the browser would if
|
|
342
|
+
# this were a raw file link, then see if some route serves
|
|
343
|
+
# that exact file.
|
|
344
|
+
resolved_path = os.path.normpath(
|
|
345
|
+
os.path.join(source_dir, parts.path)
|
|
346
|
+
)
|
|
347
|
+
full_name = registry.get(resolved_path)
|
|
348
|
+
if full_name is None:
|
|
349
|
+
logger.debug(
|
|
350
|
+
"markdown_view: no registered route for linked file "
|
|
351
|
+
"%r (resolved from %r while rendering %r).",
|
|
352
|
+
resolved_path, href, source_path,
|
|
353
|
+
)
|
|
354
|
+
else:
|
|
355
|
+
try:
|
|
356
|
+
# Resolved fresh on every call (rather than cached),
|
|
357
|
+
# since `reverse()`'s result can depend on
|
|
358
|
+
# request/runtime context -- e.g. the currently
|
|
359
|
+
# active language under `i18n_patterns()`, or the
|
|
360
|
+
# active script prefix -- not just the URLconf.
|
|
361
|
+
resolved_url = reverse(full_name)
|
|
362
|
+
except NoReverseMatch:
|
|
363
|
+
# The registry is stale relative to the active
|
|
364
|
+
# URLconf (e.g. it changed since the registry was
|
|
365
|
+
# built).
|
|
366
|
+
logger.debug(
|
|
367
|
+
"markdown_view: could not reverse %r for linked "
|
|
368
|
+
"file %r while rendering %r; falling back.",
|
|
369
|
+
full_name, resolved_path, source_path,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
if resolved_url is None:
|
|
373
|
+
if not unresolved_link_root:
|
|
374
|
+
# No registered route, and no fallback root configured --
|
|
375
|
+
# leave the link exactly as Markdown would otherwise have
|
|
376
|
+
# rendered it, rather than producing a broken URL of our
|
|
377
|
+
# own.
|
|
378
|
+
return match.group(0)
|
|
379
|
+
# `urljoin()` (rather than plain string concatenation)
|
|
380
|
+
# correctly resolves `./` and `../` path segments in the
|
|
381
|
+
# original relative link against the fallback root.
|
|
382
|
+
resolved_url = urljoin(unresolved_link_root, parts.path)
|
|
383
|
+
|
|
384
|
+
new_href = urlunsplit(("", "", resolved_url, parts.query, parts.fragment))
|
|
385
|
+
return f"{prefix}{new_href}{suffix}"
|
|
386
|
+
|
|
387
|
+
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
|
+
)
|