importpython-markdown 0.0.1__tar.gz → 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.
- importpython_markdown-0.1.0/LICENSE +21 -0
- importpython_markdown-0.1.0/PKG-INFO +72 -0
- importpython_markdown-0.1.0/README.md +51 -0
- importpython_markdown-0.1.0/pyproject.toml +31 -0
- importpython_markdown-0.1.0/setup.cfg +4 -0
- importpython_markdown-0.1.0/src/importpython_markdown/__init__.py +431 -0
- importpython_markdown-0.1.0/src/importpython_markdown.egg-info/PKG-INFO +72 -0
- importpython_markdown-0.1.0/src/importpython_markdown.egg-info/SOURCES.txt +11 -0
- importpython_markdown-0.1.0/src/importpython_markdown.egg-info/dependency_links.txt +1 -0
- importpython_markdown-0.1.0/src/importpython_markdown.egg-info/entry_points.txt +2 -0
- importpython_markdown-0.1.0/src/importpython_markdown.egg-info/requires.txt +2 -0
- importpython_markdown-0.1.0/src/importpython_markdown.egg-info/top_level.txt +1 -0
- importpython_markdown-0.1.0/tests/test_extension.py +274 -0
- importpython_markdown-0.0.1/PKG-INFO +0 -7
- importpython_markdown-0.0.1/pyproject.toml +0 -16
- importpython_markdown-0.0.1/src/importpython_markdown/__init__.py +0 -5
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nicholas H.Tollervey
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: importpython-markdown
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Runnable Python examples in Markdown, via importpython.org.
|
|
5
|
+
Author-email: "Nicholas H.Tollervey" <ntoll@ntoll.org>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://importpython.org/
|
|
8
|
+
Project-URL: Repository, https://codeberg.org/ntoll/importpython
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Documentation
|
|
14
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: markdown>=3.4
|
|
19
|
+
Requires-Dist: pygments>=2.14
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# importpython-markdown
|
|
23
|
+
|
|
24
|
+
Runnable Python examples in Markdown pages, for MkDocs, Zensical
|
|
25
|
+
and anything else built on Python-Markdown, powered by
|
|
26
|
+
[importpython.org](https://importpython.org/).
|
|
27
|
+
|
|
28
|
+
Wrap a section of ordinary Markdown in `#!` and `!#` gates:
|
|
29
|
+
|
|
30
|
+
#!mpy terminal title="A first example"
|
|
31
|
+
|
|
32
|
+
A caption, rendered as normal Markdown.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
print("Hello, world!")
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
!#
|
|
39
|
+
|
|
40
|
+
The section becomes a runnable example: the code, a Run button,
|
|
41
|
+
and the program's output beneath - nothing downloaded until a
|
|
42
|
+
reader presses Run. Plain fences are the program; a fence with
|
|
43
|
+
`title="name.py"` is a file delivered beside it; `config` and
|
|
44
|
+
`body` fences carry BFP settings and page markup (escaped for
|
|
45
|
+
you). The `py` and `mpy` types choose Pyodide or MicroPython,
|
|
46
|
+
and `terminal`, `debug`, `title="..."`, `run-label="..."`,
|
|
47
|
+
`stop-label="..."`, `theme="..."`, `class` and `id` pass through
|
|
48
|
+
to the element.
|
|
49
|
+
|
|
50
|
+
Install and enable:
|
|
51
|
+
|
|
52
|
+
pip install importpython-markdown
|
|
53
|
+
|
|
54
|
+
```yaml
|
|
55
|
+
# mkdocs.yml
|
|
56
|
+
markdown_extensions:
|
|
57
|
+
- importpython
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```toml
|
|
61
|
+
# zensical.toml
|
|
62
|
+
[project.markdown_extensions]
|
|
63
|
+
importpython = {}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Pre-release honesty: until BFP's first release, the default
|
|
67
|
+
`script_url` points at the service's test channel, which may
|
|
68
|
+
change; pin your own copy with the `script_url` option if that
|
|
69
|
+
matters to you today.
|
|
70
|
+
|
|
71
|
+
The full documentation lives at
|
|
72
|
+
[importpython.org/docs](https://importpython.org/docs/).
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# importpython-markdown
|
|
2
|
+
|
|
3
|
+
Runnable Python examples in Markdown pages, for MkDocs, Zensical
|
|
4
|
+
and anything else built on Python-Markdown, powered by
|
|
5
|
+
[importpython.org](https://importpython.org/).
|
|
6
|
+
|
|
7
|
+
Wrap a section of ordinary Markdown in `#!` and `!#` gates:
|
|
8
|
+
|
|
9
|
+
#!mpy terminal title="A first example"
|
|
10
|
+
|
|
11
|
+
A caption, rendered as normal Markdown.
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
print("Hello, world!")
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
!#
|
|
18
|
+
|
|
19
|
+
The section becomes a runnable example: the code, a Run button,
|
|
20
|
+
and the program's output beneath - nothing downloaded until a
|
|
21
|
+
reader presses Run. Plain fences are the program; a fence with
|
|
22
|
+
`title="name.py"` is a file delivered beside it; `config` and
|
|
23
|
+
`body` fences carry BFP settings and page markup (escaped for
|
|
24
|
+
you). The `py` and `mpy` types choose Pyodide or MicroPython,
|
|
25
|
+
and `terminal`, `debug`, `title="..."`, `run-label="..."`,
|
|
26
|
+
`stop-label="..."`, `theme="..."`, `class` and `id` pass through
|
|
27
|
+
to the element.
|
|
28
|
+
|
|
29
|
+
Install and enable:
|
|
30
|
+
|
|
31
|
+
pip install importpython-markdown
|
|
32
|
+
|
|
33
|
+
```yaml
|
|
34
|
+
# mkdocs.yml
|
|
35
|
+
markdown_extensions:
|
|
36
|
+
- importpython
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```toml
|
|
40
|
+
# zensical.toml
|
|
41
|
+
[project.markdown_extensions]
|
|
42
|
+
importpython = {}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Pre-release honesty: until BFP's first release, the default
|
|
46
|
+
`script_url` points at the service's test channel, which may
|
|
47
|
+
change; pin your own copy with the `script_url` option if that
|
|
48
|
+
matters to you today.
|
|
49
|
+
|
|
50
|
+
The full documentation lives at
|
|
51
|
+
[importpython.org/docs](https://importpython.org/docs/).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "importpython-markdown"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Runnable Python examples in Markdown, via importpython.org."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Nicholas H.Tollervey", email = "ntoll@ntoll.org" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = ["markdown>=3.4", "pygments>=2.14"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Documentation",
|
|
20
|
+
"Topic :: Text Processing :: Markup :: Markdown",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://importpython.org/"
|
|
25
|
+
Repository = "https://codeberg.org/ntoll/importpython"
|
|
26
|
+
|
|
27
|
+
[project.entry-points."markdown.extensions"]
|
|
28
|
+
importpython = "importpython_markdown:ImportPythonExtension"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Runnable Python examples in Markdown, for any renderer built on
|
|
3
|
+
Python-Markdown, powered by importpython.org.
|
|
4
|
+
|
|
5
|
+
A region between a ``#!`` gate line and a ``!#`` gate line
|
|
6
|
+
becomes one ``<import-python>`` element. Inside the gates,
|
|
7
|
+
ordinary Markdown is rendered as captions, and fenced code
|
|
8
|
+
blocks map onto the element's parts: a plain fence is the
|
|
9
|
+
program, ``title="name"`` on a fence is a file delivered beside
|
|
10
|
+
it, and ``config`` and ``body`` fences carry BFP's settings and
|
|
11
|
+
the frame's page markup. The plugin escapes what needs escaping,
|
|
12
|
+
so a documenter never types an entity, and it validates at build
|
|
13
|
+
time in the element's own vocabulary, so an authoring mistake is
|
|
14
|
+
a loud build failure rather than a silent page.
|
|
15
|
+
|
|
16
|
+
The emitted markup follows the element's contract exactly: each
|
|
17
|
+
part is a bare ``<pre>``, a direct child of the element, in the
|
|
18
|
+
order the author wrote it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import html
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
|
|
25
|
+
from markdown import Markdown
|
|
26
|
+
from pygments import highlight
|
|
27
|
+
from pygments.formatters import HtmlFormatter
|
|
28
|
+
from pygments.lexers import get_lexer_by_name, get_lexer_for_filename
|
|
29
|
+
from pygments.util import ClassNotFound
|
|
30
|
+
from markdown.extensions import Extension
|
|
31
|
+
from markdown.preprocessors import Preprocessor
|
|
32
|
+
|
|
33
|
+
# The gates. A #! line opens an example and carries its header;
|
|
34
|
+
# a line that is exactly !# closes it. Both are recognised only
|
|
35
|
+
# outside fenced code, so code about the gates can be quoted.
|
|
36
|
+
OPEN_GATE = re.compile(r"^#!(\S+)(?:\s+(.*))?$")
|
|
37
|
+
CLOSE_GATE = re.compile(r"^!#\s*$")
|
|
38
|
+
|
|
39
|
+
# A fence opener or closer: three or more backticks or tildes.
|
|
40
|
+
# The closer must use the same character, at least as many, and
|
|
41
|
+
# carry nothing else - the CommonMark rules this plugin needs.
|
|
42
|
+
FENCE = re.compile(r"^(`{3,}|~{3,})\s*(.*)$")
|
|
43
|
+
|
|
44
|
+
# Attributes on the gate line: bare flags and key="value" pairs.
|
|
45
|
+
ATTRIBUTE = re.compile(r'([\w-]+)(?:="([^"]*)")?')
|
|
46
|
+
|
|
47
|
+
# The element's runtimes, and what the gate may pass through to
|
|
48
|
+
# it. The allow-list is deliberate: a typo in an attribute should
|
|
49
|
+
# fail the build, not be silently ignored in every reader's
|
|
50
|
+
# browser.
|
|
51
|
+
TYPES = ("py", "mpy")
|
|
52
|
+
FLAGS = ("terminal", "debug")
|
|
53
|
+
VALUED = ("title", "run-label", "stop-label", "theme", "class", "id")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ExampleError(ValueError):
|
|
57
|
+
"""
|
|
58
|
+
An authoring mistake in an example, raised so the site build
|
|
59
|
+
fails loudly and names the problem - the documenter's version
|
|
60
|
+
of the element withholding its button.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, line, problem):
|
|
64
|
+
super().__init__(f"import-python example, line {line}: {problem}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_gate(header, line):
|
|
68
|
+
"""
|
|
69
|
+
Turn a gate header into the element's attributes, insisting on
|
|
70
|
+
a known runtime first and allow-listed attributes after it, so
|
|
71
|
+
every mistake is caught here rather than shipped.
|
|
72
|
+
"""
|
|
73
|
+
match = OPEN_GATE.match(header)
|
|
74
|
+
kind = match.group(1)
|
|
75
|
+
if kind not in TYPES:
|
|
76
|
+
raise ExampleError(
|
|
77
|
+
line, f'unknown type "{kind}"; use one of {", ".join(TYPES)}'
|
|
78
|
+
)
|
|
79
|
+
attributes = {"type": kind}
|
|
80
|
+
rest = match.group(2) or ""
|
|
81
|
+
span = 0
|
|
82
|
+
for found in ATTRIBUTE.finditer(rest):
|
|
83
|
+
if rest[span:found.start()].strip():
|
|
84
|
+
raise ExampleError(
|
|
85
|
+
line, f'cannot parse attributes at "{rest[span:]}"'
|
|
86
|
+
)
|
|
87
|
+
span = found.end()
|
|
88
|
+
name, value = found.group(1), found.group(2)
|
|
89
|
+
if value is None:
|
|
90
|
+
if name not in FLAGS:
|
|
91
|
+
raise ExampleError(
|
|
92
|
+
line,
|
|
93
|
+
f'unknown flag "{name}"; '
|
|
94
|
+
f'flags are {", ".join(FLAGS)}',
|
|
95
|
+
)
|
|
96
|
+
attributes[name] = None
|
|
97
|
+
else:
|
|
98
|
+
if name not in VALUED:
|
|
99
|
+
raise ExampleError(
|
|
100
|
+
line,
|
|
101
|
+
f'unknown attribute "{name}"; '
|
|
102
|
+
f'attributes are {", ".join(VALUED)}',
|
|
103
|
+
)
|
|
104
|
+
attributes[name] = value
|
|
105
|
+
if rest[span:].strip():
|
|
106
|
+
raise ExampleError(
|
|
107
|
+
line, f'cannot parse attributes at "{rest[span:]}"'
|
|
108
|
+
)
|
|
109
|
+
return attributes
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_fence_info(info):
|
|
113
|
+
"""
|
|
114
|
+
Decide what a fence inside the gates is for. A title makes it
|
|
115
|
+
a file; the reserved words config and body claim their roles;
|
|
116
|
+
anything else - a language name or nothing - is the program.
|
|
117
|
+
The language is kept for the highlighter to use later.
|
|
118
|
+
"""
|
|
119
|
+
title = None
|
|
120
|
+
found = re.search(r'title="([^"]*)"', info)
|
|
121
|
+
if found:
|
|
122
|
+
title = found.group(1)
|
|
123
|
+
info = (info[:found.start()] + info[found.end():]).strip()
|
|
124
|
+
language = info.split()[0] if info.split() else None
|
|
125
|
+
if title is not None:
|
|
126
|
+
return {"part": "file", "title": title, "language": language}
|
|
127
|
+
if language in ("config", "body"):
|
|
128
|
+
return {"part": language, "language": None}
|
|
129
|
+
return {"part": "program", "language": language}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def split_example(lines, first_line):
|
|
133
|
+
"""
|
|
134
|
+
Walk the region between the gates, separating fenced blocks
|
|
135
|
+
(the element's parts) from the prose between them (the
|
|
136
|
+
captions), preserving the author's order throughout.
|
|
137
|
+
"""
|
|
138
|
+
pieces = []
|
|
139
|
+
prose = []
|
|
140
|
+
fence = None
|
|
141
|
+
block = None
|
|
142
|
+
for offset, line in enumerate(lines):
|
|
143
|
+
if fence is None:
|
|
144
|
+
found = FENCE.match(line)
|
|
145
|
+
if found:
|
|
146
|
+
if prose and any(p.strip() for p in prose):
|
|
147
|
+
pieces.append({"part": "prose", "text": prose})
|
|
148
|
+
prose = []
|
|
149
|
+
fence = found.group(1)
|
|
150
|
+
block = parse_fence_info(found.group(2).strip())
|
|
151
|
+
block["text"] = []
|
|
152
|
+
block["line"] = first_line + offset
|
|
153
|
+
continue
|
|
154
|
+
prose.append(line)
|
|
155
|
+
else:
|
|
156
|
+
found = FENCE.match(line)
|
|
157
|
+
closes = (
|
|
158
|
+
found
|
|
159
|
+
and found.group(1)[0] == fence[0]
|
|
160
|
+
and len(found.group(1)) >= len(fence)
|
|
161
|
+
and not found.group(2).strip()
|
|
162
|
+
)
|
|
163
|
+
if closes:
|
|
164
|
+
pieces.append(block)
|
|
165
|
+
fence = None
|
|
166
|
+
block = None
|
|
167
|
+
continue
|
|
168
|
+
block["text"].append(line)
|
|
169
|
+
if fence is not None:
|
|
170
|
+
raise ExampleError(block["line"], "unclosed code fence")
|
|
171
|
+
if prose and any(p.strip() for p in prose):
|
|
172
|
+
pieces.append({"part": "prose", "text": prose})
|
|
173
|
+
return pieces
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def validate(pieces, line):
|
|
177
|
+
"""
|
|
178
|
+
The element's own rules, applied at build time: exactly one
|
|
179
|
+
program, no duplicate filenames, at most one config (as valid
|
|
180
|
+
JSON) and one body. The wording follows classify() in
|
|
181
|
+
embed.js, so both layers speak with one voice.
|
|
182
|
+
"""
|
|
183
|
+
programs = [p for p in pieces if p["part"] == "program"]
|
|
184
|
+
if not programs:
|
|
185
|
+
raise ExampleError(line, "no program block")
|
|
186
|
+
if len(programs) > 1:
|
|
187
|
+
raise ExampleError(programs[1]["line"], "more than one program block")
|
|
188
|
+
names = set()
|
|
189
|
+
for piece in [p for p in pieces if p["part"] == "file"]:
|
|
190
|
+
if piece["title"] in names:
|
|
191
|
+
raise ExampleError(
|
|
192
|
+
piece["line"],
|
|
193
|
+
f'two blocks name the file "{piece["title"]}"',
|
|
194
|
+
)
|
|
195
|
+
names.add(piece["title"])
|
|
196
|
+
for role in ("config", "body"):
|
|
197
|
+
found = [p for p in pieces if p["part"] == role]
|
|
198
|
+
if len(found) > 1:
|
|
199
|
+
raise ExampleError(
|
|
200
|
+
found[1]["line"], f"more than one {role} block"
|
|
201
|
+
)
|
|
202
|
+
for piece in [p for p in pieces if p["part"] == "config"]:
|
|
203
|
+
try:
|
|
204
|
+
json.loads("\n".join(piece["text"]))
|
|
205
|
+
except json.JSONDecodeError as error:
|
|
206
|
+
raise ExampleError(
|
|
207
|
+
piece["line"],
|
|
208
|
+
f"the config block is not valid JSON: {error}",
|
|
209
|
+
) from None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class ImportPythonPreprocessor(Preprocessor):
|
|
213
|
+
"""
|
|
214
|
+
Find every gated region, build its element, and stash the
|
|
215
|
+
result so the surrounding renderer treats it as opaque HTML.
|
|
216
|
+
Fence state is tracked at the top level too, so gates quoted
|
|
217
|
+
inside ordinary code blocks stay code.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
def __init__(self, md, config):
|
|
221
|
+
super().__init__(md)
|
|
222
|
+
self.config = config
|
|
223
|
+
|
|
224
|
+
def run(self, lines):
|
|
225
|
+
out = []
|
|
226
|
+
example = None
|
|
227
|
+
first = 0
|
|
228
|
+
fence = None
|
|
229
|
+
emitted = False
|
|
230
|
+
for number, line in enumerate(lines, start=1):
|
|
231
|
+
if example is None:
|
|
232
|
+
found = FENCE.match(line)
|
|
233
|
+
if fence is None and OPEN_GATE.match(line):
|
|
234
|
+
example = []
|
|
235
|
+
first = number
|
|
236
|
+
continue
|
|
237
|
+
if found:
|
|
238
|
+
if fence is None:
|
|
239
|
+
fence = found.group(1)
|
|
240
|
+
elif (
|
|
241
|
+
found.group(1)[0] == fence[0]
|
|
242
|
+
and len(found.group(1)) >= len(fence)
|
|
243
|
+
and not found.group(2).strip()
|
|
244
|
+
):
|
|
245
|
+
fence = None
|
|
246
|
+
out.append(line)
|
|
247
|
+
else:
|
|
248
|
+
if CLOSE_GATE.match(line) and not self.inside_fence(
|
|
249
|
+
example
|
|
250
|
+
):
|
|
251
|
+
element = self.render(
|
|
252
|
+
lines[first - 1], example, first
|
|
253
|
+
)
|
|
254
|
+
out.append("")
|
|
255
|
+
out.append(self.md.htmlStash.store(element))
|
|
256
|
+
out.append("")
|
|
257
|
+
example = None
|
|
258
|
+
emitted = True
|
|
259
|
+
continue
|
|
260
|
+
example.append(line)
|
|
261
|
+
if example is not None:
|
|
262
|
+
raise ExampleError(first, "unclosed #! gate: no matching !#")
|
|
263
|
+
if emitted and self.config["inject_script"]:
|
|
264
|
+
# As a literal line rather than a stash entry: script
|
|
265
|
+
# opens an HTML block, so Markdown passes it through
|
|
266
|
+
# unwrapped, where a stashed placeholder at document
|
|
267
|
+
# end can be left inside a paragraph.
|
|
268
|
+
out.append("")
|
|
269
|
+
out.append(
|
|
270
|
+
'<script type="module" src="'
|
|
271
|
+
+ html.escape(self.config["script_url"])
|
|
272
|
+
+ '"></script>'
|
|
273
|
+
)
|
|
274
|
+
out.append("")
|
|
275
|
+
return out
|
|
276
|
+
|
|
277
|
+
def inside_fence(self, lines):
|
|
278
|
+
"""
|
|
279
|
+
Whether the region so far ends inside a fenced block, in
|
|
280
|
+
which case a !# line is content rather than the gate.
|
|
281
|
+
"""
|
|
282
|
+
fence = None
|
|
283
|
+
for line in lines:
|
|
284
|
+
found = FENCE.match(line)
|
|
285
|
+
if not found:
|
|
286
|
+
continue
|
|
287
|
+
if fence is None:
|
|
288
|
+
fence = found.group(1)
|
|
289
|
+
elif (
|
|
290
|
+
found.group(1)[0] == fence[0]
|
|
291
|
+
and len(found.group(1)) >= len(fence)
|
|
292
|
+
and not found.group(2).strip()
|
|
293
|
+
):
|
|
294
|
+
fence = None
|
|
295
|
+
return fence is not None
|
|
296
|
+
|
|
297
|
+
def render(self, header, lines, first_line):
|
|
298
|
+
"""
|
|
299
|
+
One example: parse, validate, and emit the element with
|
|
300
|
+
its parts and captions in the author's order.
|
|
301
|
+
"""
|
|
302
|
+
attributes = parse_gate(header, first_line)
|
|
303
|
+
pieces = split_example(lines, first_line + 1)
|
|
304
|
+
validate(pieces, first_line)
|
|
305
|
+
parts = ["<import-python"]
|
|
306
|
+
for name, value in attributes.items():
|
|
307
|
+
if value is None:
|
|
308
|
+
parts.append(f" {name}")
|
|
309
|
+
else:
|
|
310
|
+
parts.append(f' {name}="{html.escape(value)}"')
|
|
311
|
+
parts.append(">\n")
|
|
312
|
+
for piece in pieces:
|
|
313
|
+
parts.append(self.render_piece(piece))
|
|
314
|
+
parts.append("</import-python>")
|
|
315
|
+
return "".join(parts)
|
|
316
|
+
|
|
317
|
+
def render_piece(self, piece):
|
|
318
|
+
"""
|
|
319
|
+
A caption renders as Markdown through a fresh renderer,
|
|
320
|
+
so the example's own gates cannot nest; a part becomes a
|
|
321
|
+
bare pre, a direct child - the element's contract - with
|
|
322
|
+
its text either highlighted or escaped, and never both by
|
|
323
|
+
hand: Pygments escapes what it colours, and the plain
|
|
324
|
+
path escapes here, so the author never types an entity.
|
|
325
|
+
That is also what makes a body block safe to write as
|
|
326
|
+
plain markup.
|
|
327
|
+
"""
|
|
328
|
+
if piece["part"] == "prose":
|
|
329
|
+
inner = Markdown(
|
|
330
|
+
extensions=self.config["inner_extensions"]
|
|
331
|
+
)
|
|
332
|
+
return inner.convert("\n".join(piece["text"])) + "\n"
|
|
333
|
+
text, lit = self.paint("\n".join(piece["text"]), piece)
|
|
334
|
+
if piece["part"] == "file":
|
|
335
|
+
title = html.escape(piece["title"])
|
|
336
|
+
return f'<pre{lit} title="{title}">\n{text}\n</pre>\n'
|
|
337
|
+
if piece["part"] in ("config", "body"):
|
|
338
|
+
role = piece["part"]
|
|
339
|
+
return f'<pre{lit} data-role="{role}">\n{text}\n</pre>\n'
|
|
340
|
+
return f"<pre{lit}>\n{text}\n</pre>\n"
|
|
341
|
+
|
|
342
|
+
def paint(self, code, piece):
|
|
343
|
+
"""
|
|
344
|
+
Highlight a part's code as Pygments spans with no wrapper
|
|
345
|
+
of any kind, so the pre stays a bare direct child and its
|
|
346
|
+
textContent stays exactly the code. The class="highlight"
|
|
347
|
+
the coloured path adds is what lets a Material or
|
|
348
|
+
Zensical site's existing Pygments stylesheet colour these
|
|
349
|
+
blocks, palettes and all, with no CSS of ours.
|
|
350
|
+
|
|
351
|
+
The language comes from the fence, or a file's name, with
|
|
352
|
+
json and html assumed for config and body; an unknown or
|
|
353
|
+
absent language falls back to plain escaped text, as the
|
|
354
|
+
wider ecosystem does, because colour is decoration rather
|
|
355
|
+
than contract.
|
|
356
|
+
"""
|
|
357
|
+
lexer = None
|
|
358
|
+
if self.config["highlight"]:
|
|
359
|
+
language = piece.get("language")
|
|
360
|
+
if piece["part"] == "config":
|
|
361
|
+
language = "json"
|
|
362
|
+
elif piece["part"] == "body":
|
|
363
|
+
language = "html"
|
|
364
|
+
try:
|
|
365
|
+
if language:
|
|
366
|
+
lexer = get_lexer_by_name(language)
|
|
367
|
+
elif piece["part"] == "file":
|
|
368
|
+
lexer = get_lexer_for_filename(piece["title"])
|
|
369
|
+
except ClassNotFound:
|
|
370
|
+
lexer = None
|
|
371
|
+
if lexer is None:
|
|
372
|
+
return html.escape(code, quote=False), ""
|
|
373
|
+
painted = highlight(
|
|
374
|
+
code, lexer, HtmlFormatter(nowrap=True)
|
|
375
|
+
).rstrip("\n")
|
|
376
|
+
return painted, ' class="highlight"'
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class ImportPythonExtension(Extension):
|
|
380
|
+
"""
|
|
381
|
+
The extension: register the preprocessor early, before other
|
|
382
|
+
preprocessors can mistake the gated region for their own.
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
def __init__(self, **kwargs):
|
|
386
|
+
self.config = {
|
|
387
|
+
"script_url": [
|
|
388
|
+
"https://importpython.org/bfp/test/embed.js",
|
|
389
|
+
"Where embed.js is served from; point this at "
|
|
390
|
+
"your own copy to use one.",
|
|
391
|
+
],
|
|
392
|
+
"inject_script": [
|
|
393
|
+
True,
|
|
394
|
+
"Append one script tag to any page that carries "
|
|
395
|
+
"an example. Turn off for sites that load "
|
|
396
|
+
"embed.js themselves.",
|
|
397
|
+
],
|
|
398
|
+
"inner_extensions": [
|
|
399
|
+
[],
|
|
400
|
+
"Markdown extensions applied to the captions "
|
|
401
|
+
"inside an example.",
|
|
402
|
+
],
|
|
403
|
+
"highlight": [
|
|
404
|
+
True,
|
|
405
|
+
"Colour the code with Pygments spans inside the "
|
|
406
|
+
"bare pre, marked class=\"highlight\" so a "
|
|
407
|
+
"site's existing Pygments stylesheet applies.",
|
|
408
|
+
],
|
|
409
|
+
}
|
|
410
|
+
super().__init__(**kwargs)
|
|
411
|
+
|
|
412
|
+
def extendMarkdown(self, md):
|
|
413
|
+
# The element must be known as block-level, or the raw
|
|
414
|
+
# HTML postprocessor leaves the stashed element wrapped
|
|
415
|
+
# in a paragraph - and a pre inside a p makes browsers
|
|
416
|
+
# close the paragraph early, structurally breaking the
|
|
417
|
+
# direct-child contract the plugin exists to honour.
|
|
418
|
+
md.block_level_elements.append("import-python")
|
|
419
|
+
md.preprocessors.register(
|
|
420
|
+
ImportPythonPreprocessor(md, self.getConfigs()),
|
|
421
|
+
"import-python",
|
|
422
|
+
28,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def makeExtension(**kwargs):
|
|
427
|
+
"""
|
|
428
|
+
The hook Python-Markdown looks for when the extension is
|
|
429
|
+
named by its module path.
|
|
430
|
+
"""
|
|
431
|
+
return ImportPythonExtension(**kwargs)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: importpython-markdown
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Runnable Python examples in Markdown, via importpython.org.
|
|
5
|
+
Author-email: "Nicholas H.Tollervey" <ntoll@ntoll.org>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://importpython.org/
|
|
8
|
+
Project-URL: Repository, https://codeberg.org/ntoll/importpython
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Documentation
|
|
14
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: markdown>=3.4
|
|
19
|
+
Requires-Dist: pygments>=2.14
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# importpython-markdown
|
|
23
|
+
|
|
24
|
+
Runnable Python examples in Markdown pages, for MkDocs, Zensical
|
|
25
|
+
and anything else built on Python-Markdown, powered by
|
|
26
|
+
[importpython.org](https://importpython.org/).
|
|
27
|
+
|
|
28
|
+
Wrap a section of ordinary Markdown in `#!` and `!#` gates:
|
|
29
|
+
|
|
30
|
+
#!mpy terminal title="A first example"
|
|
31
|
+
|
|
32
|
+
A caption, rendered as normal Markdown.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
print("Hello, world!")
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
!#
|
|
39
|
+
|
|
40
|
+
The section becomes a runnable example: the code, a Run button,
|
|
41
|
+
and the program's output beneath - nothing downloaded until a
|
|
42
|
+
reader presses Run. Plain fences are the program; a fence with
|
|
43
|
+
`title="name.py"` is a file delivered beside it; `config` and
|
|
44
|
+
`body` fences carry BFP settings and page markup (escaped for
|
|
45
|
+
you). The `py` and `mpy` types choose Pyodide or MicroPython,
|
|
46
|
+
and `terminal`, `debug`, `title="..."`, `run-label="..."`,
|
|
47
|
+
`stop-label="..."`, `theme="..."`, `class` and `id` pass through
|
|
48
|
+
to the element.
|
|
49
|
+
|
|
50
|
+
Install and enable:
|
|
51
|
+
|
|
52
|
+
pip install importpython-markdown
|
|
53
|
+
|
|
54
|
+
```yaml
|
|
55
|
+
# mkdocs.yml
|
|
56
|
+
markdown_extensions:
|
|
57
|
+
- importpython
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```toml
|
|
61
|
+
# zensical.toml
|
|
62
|
+
[project.markdown_extensions]
|
|
63
|
+
importpython = {}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Pre-release honesty: until BFP's first release, the default
|
|
67
|
+
`script_url` points at the service's test channel, which may
|
|
68
|
+
change; pin your own copy with the `script_url` option if that
|
|
69
|
+
matters to you today.
|
|
70
|
+
|
|
71
|
+
The full documentation lives at
|
|
72
|
+
[importpython.org/docs](https://importpython.org/docs/).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/importpython_markdown/__init__.py
|
|
5
|
+
src/importpython_markdown.egg-info/PKG-INFO
|
|
6
|
+
src/importpython_markdown.egg-info/SOURCES.txt
|
|
7
|
+
src/importpython_markdown.egg-info/dependency_links.txt
|
|
8
|
+
src/importpython_markdown.egg-info/entry_points.txt
|
|
9
|
+
src/importpython_markdown.egg-info/requires.txt
|
|
10
|
+
src/importpython_markdown.egg-info/top_level.txt
|
|
11
|
+
tests/test_extension.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
importpython_markdown
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for importpython-markdown: the gates, the mappings,
|
|
3
|
+
the escaping, the validation, and the script tag - one test per
|
|
4
|
+
rule, so a failure names its rule.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import markdown
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from importpython_markdown import ExampleError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def render(text, **options):
|
|
14
|
+
"""
|
|
15
|
+
Render Markdown with the extension configured as a user
|
|
16
|
+
would configure it.
|
|
17
|
+
"""
|
|
18
|
+
md = markdown.Markdown(
|
|
19
|
+
extensions=["importpython_markdown"],
|
|
20
|
+
extension_configs={"importpython_markdown": options},
|
|
21
|
+
)
|
|
22
|
+
return md.convert(text)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
EXAMPLE = """\
|
|
26
|
+
#!py terminal debug
|
|
27
|
+
|
|
28
|
+
Here is the Foo module.
|
|
29
|
+
|
|
30
|
+
```title="foo.py"
|
|
31
|
+
def foo():
|
|
32
|
+
return 42
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
* Some
|
|
36
|
+
* Random
|
|
37
|
+
* Markdown
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
import foo
|
|
41
|
+
|
|
42
|
+
print(foo.foo())
|
|
43
|
+
```
|
|
44
|
+
!#
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_the_worked_example():
|
|
49
|
+
"""
|
|
50
|
+
Intent: the example from the design conversation renders as
|
|
51
|
+
one element with a file, two captions, and the program, in
|
|
52
|
+
the author's order, with the flags carried across.
|
|
53
|
+
"""
|
|
54
|
+
out = render(EXAMPLE)
|
|
55
|
+
assert '<import-python type="py" terminal debug>' in out
|
|
56
|
+
assert 'title="foo.py"' in out
|
|
57
|
+
assert "foo" in out
|
|
58
|
+
assert "<li>Random</li>" in out
|
|
59
|
+
assert "print(foo.foo())" in out
|
|
60
|
+
assert out.index("Foo module") < out.index('title="foo.py"')
|
|
61
|
+
assert out.index("<li>Some") < out.index("import foo")
|
|
62
|
+
assert out.count("<import-python") == 1
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_untouched_without_gates():
|
|
66
|
+
"""
|
|
67
|
+
Intent: a document with no gates is not our business, and no
|
|
68
|
+
script tag is added to it.
|
|
69
|
+
"""
|
|
70
|
+
out = render("# Hello\n\nJust prose.\n")
|
|
71
|
+
assert "import-python" not in out
|
|
72
|
+
assert "embed.js" not in out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_gate_inside_a_fence_is_code():
|
|
76
|
+
"""
|
|
77
|
+
Intent: documentation about the gates can quote them - a #!
|
|
78
|
+
line inside an ordinary fenced block opens nothing.
|
|
79
|
+
"""
|
|
80
|
+
out = render("```\n#!py\n!#\n```\n")
|
|
81
|
+
assert "import-python" not in out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_close_gate_inside_inner_fence_is_content():
|
|
85
|
+
"""
|
|
86
|
+
Intent: a !# inside a fenced block inside the gates is code
|
|
87
|
+
(somebody's shell snippet), not the closing gate.
|
|
88
|
+
"""
|
|
89
|
+
out = render(
|
|
90
|
+
'#!mpy\n```title="notes.sh"\n!#\n```\n\n'
|
|
91
|
+
"```\nprint('hi')\n```\n!#\n"
|
|
92
|
+
)
|
|
93
|
+
assert out.count("<import-python") == 1
|
|
94
|
+
assert ">\n!#\n</pre>" in out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_attributes_pass_through_escaped():
|
|
98
|
+
"""
|
|
99
|
+
Intent: valued attributes reach the element, HTML-escaped,
|
|
100
|
+
and the accessible-name convention survives the trip.
|
|
101
|
+
"""
|
|
102
|
+
out = render(
|
|
103
|
+
'#!mpy title="A & B" run-label="Go"\n\n'
|
|
104
|
+
"```\nprint(1)\n```\n!#\n"
|
|
105
|
+
)
|
|
106
|
+
assert 'title="A & B"' in out
|
|
107
|
+
assert 'run-label="Go"' in out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_program_is_escaped():
|
|
111
|
+
"""
|
|
112
|
+
Intent: the two characters HTML cares about are escaped for
|
|
113
|
+
the author, so what runs is what was written.
|
|
114
|
+
"""
|
|
115
|
+
out = render(
|
|
116
|
+
"#!mpy\n\n```\nprint(1 < 2 & 3)\n```\n!#\n",
|
|
117
|
+
highlight=False,
|
|
118
|
+
)
|
|
119
|
+
assert "print(1 < 2 & 3)" in out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_body_is_escaped_for_the_author():
|
|
123
|
+
"""
|
|
124
|
+
Intent: a body block is written as plain markup and arrives
|
|
125
|
+
escaped, the two-character chore done by the machine.
|
|
126
|
+
"""
|
|
127
|
+
out = render(
|
|
128
|
+
"#!mpy\n\n```body\n<p id=\"out\"></p>\n```\n\n"
|
|
129
|
+
"```\nprint(1)\n```\n!#\n"
|
|
130
|
+
)
|
|
131
|
+
assert 'data-role="body"' in out
|
|
132
|
+
assert "<" in out and "<p id=" not in out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_config_maps_and_validates():
|
|
136
|
+
"""
|
|
137
|
+
Intent: a config fence becomes the config block, and only
|
|
138
|
+
valid JSON is allowed through.
|
|
139
|
+
"""
|
|
140
|
+
out = render(
|
|
141
|
+
'#!py\n\n```config\n{ "packages": ["arrr"] }\n```\n\n'
|
|
142
|
+
"```\nimport arrr\n```\n!#\n"
|
|
143
|
+
)
|
|
144
|
+
assert 'data-role="config"' in out
|
|
145
|
+
with pytest.raises(ExampleError, match="not valid JSON"):
|
|
146
|
+
render("#!py\n\n```config\nnope\n```\n\n```\nx\n```\n!#\n")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_unknown_type_fails_the_build():
|
|
150
|
+
"""
|
|
151
|
+
Intent: a typo in the runtime is a build failure that names
|
|
152
|
+
the valid choices.
|
|
153
|
+
"""
|
|
154
|
+
with pytest.raises(ExampleError, match='unknown type "pyy"'):
|
|
155
|
+
render("#!pyy\n\n```\nx\n```\n!#\n")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_unknown_flag_and_attribute_fail_the_build():
|
|
159
|
+
"""
|
|
160
|
+
Intent: the allow-list catches typos at build time rather
|
|
161
|
+
than shipping silently ignored attributes.
|
|
162
|
+
"""
|
|
163
|
+
with pytest.raises(ExampleError, match='unknown flag "terminl"'):
|
|
164
|
+
render("#!py terminl\n\n```\nx\n```\n!#\n")
|
|
165
|
+
with pytest.raises(ExampleError, match='unknown attribute "titel"'):
|
|
166
|
+
render('#!py titel="x"\n\n```\nx\n```\n!#\n')
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_the_element_rules_apply_at_build_time():
|
|
170
|
+
"""
|
|
171
|
+
Intent: classify()'s structural rules, spoken here in the
|
|
172
|
+
same words: one program, no duplicate filenames, one config.
|
|
173
|
+
"""
|
|
174
|
+
with pytest.raises(ExampleError, match="no program block"):
|
|
175
|
+
render("#!py\n\nJust prose.\n!#\n")
|
|
176
|
+
with pytest.raises(ExampleError, match="more than one program"):
|
|
177
|
+
render("#!py\n\n```\na\n```\n\n```\nb\n```\n!#\n")
|
|
178
|
+
with pytest.raises(ExampleError, match='name the file "a.py"'):
|
|
179
|
+
render(
|
|
180
|
+
'#!py\n\n```title="a.py"\nx\n```\n\n'
|
|
181
|
+
'```title="a.py"\ny\n```\n\n```\nz\n```\n!#\n'
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_unclosed_gate_fails_the_build():
|
|
186
|
+
"""
|
|
187
|
+
Intent: a forgotten !# is a named build failure, not a page
|
|
188
|
+
quietly swallowed to its end.
|
|
189
|
+
"""
|
|
190
|
+
with pytest.raises(ExampleError, match="unclosed #! gate"):
|
|
191
|
+
render("#!py\n\n```\nx\n```\n")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_script_tag_once_for_many_examples():
|
|
195
|
+
"""
|
|
196
|
+
Intent: a page with examples gets exactly one script tag,
|
|
197
|
+
however many examples it carries, and its URL is the
|
|
198
|
+
configured one.
|
|
199
|
+
"""
|
|
200
|
+
two = EXAMPLE + "\n" + EXAMPLE
|
|
201
|
+
out = render(two, script_url="/bfp/test/embed.js")
|
|
202
|
+
assert out.count("<import-python") == 2
|
|
203
|
+
assert out.count('src="/bfp/test/embed.js"') == 1
|
|
204
|
+
assert 'type="module"' in out
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_script_injection_can_be_turned_off():
|
|
208
|
+
"""
|
|
209
|
+
Intent: sites loading embed.js themselves can say so and get
|
|
210
|
+
no second copy.
|
|
211
|
+
"""
|
|
212
|
+
out = render(EXAMPLE, inject_script=False)
|
|
213
|
+
assert "embed.js" not in out
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_highlighting_is_spans_in_a_bare_pre():
|
|
217
|
+
"""
|
|
218
|
+
Intent: colour arrives as Pygments spans inside the bare pre
|
|
219
|
+
direct child, marked class="highlight" so a site's existing
|
|
220
|
+
Pygments stylesheet applies - never a wrapper element, which
|
|
221
|
+
would hide the pre from the element.
|
|
222
|
+
"""
|
|
223
|
+
out = render("#!mpy\n\n```python\nprint(1)\n```\n!#\n")
|
|
224
|
+
assert '<pre class="highlight">' in out
|
|
225
|
+
assert '<span class="nb">print</span>' in out
|
|
226
|
+
assert "<div" not in out
|
|
227
|
+
assert "<code" not in out
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_file_language_guessed_from_its_name():
|
|
231
|
+
"""
|
|
232
|
+
Intent: a file block with no language on the fence is
|
|
233
|
+
coloured by its filename's extension.
|
|
234
|
+
"""
|
|
235
|
+
out = render(
|
|
236
|
+
'#!mpy\n\n```title="a.py"\nx = 1\n```\n\n'
|
|
237
|
+
"```\nimport a\n```\n!#\n"
|
|
238
|
+
)
|
|
239
|
+
assert 'class="highlight" title="a.py"' in out
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_unknown_language_falls_back_to_plain():
|
|
243
|
+
"""
|
|
244
|
+
Intent: colour is decoration, not contract - a language
|
|
245
|
+
Pygments does not know renders plain and escaped, exactly as
|
|
246
|
+
the wider ecosystem behaves.
|
|
247
|
+
"""
|
|
248
|
+
out = render("#!mpy\n\n```pyton\nprint(1 < 2)\n```\n!#\n")
|
|
249
|
+
assert "<span" not in out
|
|
250
|
+
assert "1 < 2" in out
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_config_and_body_get_their_languages():
|
|
254
|
+
"""
|
|
255
|
+
Intent: config is coloured as JSON and body as HTML, with the
|
|
256
|
+
body's markup still escaped so its textContent is the markup.
|
|
257
|
+
"""
|
|
258
|
+
out = render(
|
|
259
|
+
'#!py\n\n```config\n{ "packages": ["x"] }\n```\n\n'
|
|
260
|
+
'```body\n<p id="o"></p>\n```\n\n```\nz\n```\n!#\n'
|
|
261
|
+
)
|
|
262
|
+
assert out.count("<span") >= 2
|
|
263
|
+
assert "<p id=" not in out
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_the_element_is_never_paragraph_wrapped():
|
|
267
|
+
"""
|
|
268
|
+
Intent: the element and the script tag stand as blocks - a
|
|
269
|
+
pre inside a paragraph would make browsers close the
|
|
270
|
+
paragraph early and break the direct-child contract.
|
|
271
|
+
"""
|
|
272
|
+
out = render(EXAMPLE)
|
|
273
|
+
assert "<p><import-python" not in out
|
|
274
|
+
assert "<p><script" not in out
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["hatchling"]
|
|
3
|
-
build-backend = "hatchling.build"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "importpython-markdown"
|
|
7
|
-
dynamic = ["version"]
|
|
8
|
-
description = "A markdown plugin to use importpython in docs."
|
|
9
|
-
requires-python = ">=3.11"
|
|
10
|
-
license = "MIT"
|
|
11
|
-
authors = [
|
|
12
|
-
{name = "Nicholas H.Tollervey", email = "ntoll@ntoll.org"},
|
|
13
|
-
]
|
|
14
|
-
|
|
15
|
-
[tool.hatch.version]
|
|
16
|
-
path = "src/importpython_markdown/__init__.py"
|