mymarkup 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.
- mymarkup-0.1.0/PKG-INFO +5 -0
- mymarkup-0.1.0/pyproject.toml +21 -0
- mymarkup-0.1.0/setup.cfg +4 -0
- mymarkup-0.1.0/src/mymarkup/__init__.py +1 -0
- mymarkup-0.1.0/src/mymarkup/__main__.py +125 -0
- mymarkup-0.1.0/src/mymarkup/mymarkup.py +467 -0
- mymarkup-0.1.0/src/mymarkup/styles.css +246 -0
- mymarkup-0.1.0/src/mymarkup/template.html +21 -0
- mymarkup-0.1.0/src/mymarkup/tests.py +324 -0
- mymarkup-0.1.0/src/mymarkup.egg-info/PKG-INFO +5 -0
- mymarkup-0.1.0/src/mymarkup.egg-info/SOURCES.txt +12 -0
- mymarkup-0.1.0/src/mymarkup.egg-info/dependency_links.txt +1 -0
- mymarkup-0.1.0/src/mymarkup.egg-info/entry_points.txt +2 -0
- mymarkup-0.1.0/src/mymarkup.egg-info/top_level.txt +1 -0
mymarkup-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mymarkup"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "mymarkup package"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
mymarkup = "mymarkup.__main__:main"
|
|
13
|
+
|
|
14
|
+
[tool.setuptools]
|
|
15
|
+
package-dir = {"" = "src"}
|
|
16
|
+
|
|
17
|
+
[tool.setuptools.package-data]
|
|
18
|
+
mymarkup = ["styles.css", "template.html"]
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
where = ["src"]
|
mymarkup-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .mymarkup import Context, render, metadata
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from html import escape
|
|
3
|
+
import traceback
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
|
|
7
|
+
import mymarkup
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Context(mymarkup.Context):
|
|
11
|
+
def __init__(self, root_directory: Path, relative_path: Path):
|
|
12
|
+
self._index = self.generate_index_html(root_directory, relative_path)
|
|
13
|
+
self._breadcrumbs = self.generate_breadcrumbs_html(root_directory, relative_path)
|
|
14
|
+
|
|
15
|
+
def index(self):
|
|
16
|
+
return self._index
|
|
17
|
+
|
|
18
|
+
def breadcrumbs(self):
|
|
19
|
+
return self._breadcrumbs
|
|
20
|
+
|
|
21
|
+
def generate_index_html(self, root_directory: Path, relative_path: Path) -> str:
|
|
22
|
+
html = "<table><tr><th>Name</th><th>Description</th></tr>"
|
|
23
|
+
directory = root_directory / relative_path.parent
|
|
24
|
+
for path in directory.iterdir():
|
|
25
|
+
if path.name == relative_path.name:
|
|
26
|
+
continue
|
|
27
|
+
if path.is_dir():
|
|
28
|
+
href = path.name
|
|
29
|
+
path = path / "index.mu"
|
|
30
|
+
else:
|
|
31
|
+
href = path.with_suffix(".html").name
|
|
32
|
+
if not (path.exists() and path.suffix == ".mu"):
|
|
33
|
+
continue
|
|
34
|
+
source = path.read_text(encoding="utf-8")
|
|
35
|
+
metadata = mymarkup.metadata(source)
|
|
36
|
+
html += f'<tr><td><a href="{escape(href, quote=True)}">{metadata.title}</a></td><td>{metadata.description}</td></tr>'
|
|
37
|
+
html += "</table>"
|
|
38
|
+
return html
|
|
39
|
+
|
|
40
|
+
def generate_breadcrumbs_html(self, root_directory: Path, relative_path: Path) -> str:
|
|
41
|
+
breadcrumbs = [("Home", "/")]
|
|
42
|
+
current = root_directory
|
|
43
|
+
parts = relative_path.parts[:-1] if relative_path.name == "index.mu" else relative_path.parts
|
|
44
|
+
href_parts = []
|
|
45
|
+
for name in parts:
|
|
46
|
+
current = current / name
|
|
47
|
+
href_parts.append(name)
|
|
48
|
+
if current.is_dir():
|
|
49
|
+
source = (current / "index.mu").read_text(encoding="utf-8")
|
|
50
|
+
title = mymarkup.metadata(source).title
|
|
51
|
+
elif current.suffix == ".mu":
|
|
52
|
+
source = current.read_text(encoding="utf-8")
|
|
53
|
+
title = mymarkup.metadata(source).title
|
|
54
|
+
else:
|
|
55
|
+
raise Exception(f"Unknown file type when generating breadcrumbs: {current}")
|
|
56
|
+
href = "/" + "/".join([escape(x) for x in href_parts]) + "/"
|
|
57
|
+
breadcrumbs.append((title, href))
|
|
58
|
+
html = '<div class="breadcrumbs">'
|
|
59
|
+
for title, href in breadcrumbs[:-1]:
|
|
60
|
+
html += f'<a href="{href}">{title}</a> / '
|
|
61
|
+
html += f'{breadcrumbs[-1][0]}</div>'
|
|
62
|
+
return html
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_all_markup_paths(directory: Path) -> list[Path]:
|
|
66
|
+
return [
|
|
67
|
+
path.relative_to(directory)
|
|
68
|
+
for path in directory.rglob("*.mu")
|
|
69
|
+
if path.is_file()
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def convert_markup_to_html(root_directory: Path, relative_path: Path) -> None:
|
|
74
|
+
input_path = root_directory / relative_path
|
|
75
|
+
output_path = root_directory / relative_path.with_suffix(".html")
|
|
76
|
+
input_text = input_path.read_text(encoding="utf-8")
|
|
77
|
+
input_text = f":breadcrumbs:\n\n{input_text}"
|
|
78
|
+
context = Context(root_directory, relative_path)
|
|
79
|
+
markup = mymarkup.render(input_text, context)
|
|
80
|
+
title = mymarkup.metadata(input_text, context).title
|
|
81
|
+
template = (Path(__file__).parent / "template.html").read_text(encoding="utf-8")
|
|
82
|
+
html = template.replace("TITLE", title).replace("MARKUP", markup)
|
|
83
|
+
output_path.write_text(html, encoding="utf-8")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def rm_html_files(root_directory: Path) -> int:
|
|
87
|
+
ignore_lines = [x.strip() for x in Path(".gitignore").read_text(encoding="utf-8").split("\n") if x.strip()]
|
|
88
|
+
count = 0
|
|
89
|
+
for path in root_directory.rglob("*.html"):
|
|
90
|
+
if any([part in ignore_lines for part in path.parts]):
|
|
91
|
+
continue
|
|
92
|
+
if path.is_file():
|
|
93
|
+
path.unlink()
|
|
94
|
+
count += 1
|
|
95
|
+
return count
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_site(root_directory: Path) -> None:
|
|
99
|
+
root_directory = root_directory.resolve()
|
|
100
|
+
print(f" [+] Removing all .html files from {root_directory}")
|
|
101
|
+
count = rm_html_files(root_directory)
|
|
102
|
+
print(f" [+] Removed {count} .html files")
|
|
103
|
+
relative_markup_paths = get_all_markup_paths(root_directory)
|
|
104
|
+
print(f" [+] Detected {len(relative_markup_paths)} files to to render")
|
|
105
|
+
for relative_markup_path in relative_markup_paths:
|
|
106
|
+
print(f" [+] Rendering {relative_markup_path}")
|
|
107
|
+
try:
|
|
108
|
+
convert_markup_to_html(root_directory, relative_markup_path)
|
|
109
|
+
except Exception as e:
|
|
110
|
+
raise Exception(f"Error processing {relative_markup_path}: {e}")
|
|
111
|
+
print(" [+] Copying styles.css")
|
|
112
|
+
shutil.copy2(Path(__file__).parent / "styles.css", root_directory / "styles.css")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main() -> int:
|
|
116
|
+
try:
|
|
117
|
+
build_site(Path("."))
|
|
118
|
+
return 0
|
|
119
|
+
except Exception as e:
|
|
120
|
+
print(f" [-] {e}")
|
|
121
|
+
return 1
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import inspect
|
|
3
|
+
from html import escape
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
SAFE_SCHEMES = {"http", "https", "mailto"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Metadata:
|
|
14
|
+
title: str
|
|
15
|
+
description: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Context:
|
|
19
|
+
def index(self):
|
|
20
|
+
raise Exception("index undefined")
|
|
21
|
+
|
|
22
|
+
def breadcrumbs(self):
|
|
23
|
+
raise Exception("breadcrumbs undefined")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Token:
|
|
27
|
+
def render(self, context: Context):
|
|
28
|
+
raise Exception("render undefined")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BlockToken(Token):
|
|
32
|
+
subclasses = []
|
|
33
|
+
|
|
34
|
+
def __init_subclass__(cls):
|
|
35
|
+
BlockToken.subclasses.append(cls)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def parse(_, lines: list[str], i: int) -> tuple["BlockToken", int]:
|
|
39
|
+
for cls in BlockToken.subclasses:
|
|
40
|
+
token, i = cls.parse(lines, i)
|
|
41
|
+
if token:
|
|
42
|
+
return token, i
|
|
43
|
+
raise Exception(f"No block token matched line {i}: {lines[i]!r}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class InlineToken(Token):
|
|
47
|
+
subclasses = []
|
|
48
|
+
|
|
49
|
+
def __init_subclass__(cls):
|
|
50
|
+
InlineToken.subclasses.append(cls)
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def parse(_, line: str, i: int) -> tuple["InlineToken", int]:
|
|
54
|
+
for cls in InlineToken.subclasses:
|
|
55
|
+
token, i = cls.parse(line, i)
|
|
56
|
+
if token:
|
|
57
|
+
return token, i
|
|
58
|
+
raise Exception(f"No inline token matched character {i}: {line[i:]!r}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Document(Token):
|
|
63
|
+
children: list[BlockToken]
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def parse(cls, lines: list[str]) -> "Document":
|
|
67
|
+
children = []
|
|
68
|
+
i = 0
|
|
69
|
+
while i < len(lines):
|
|
70
|
+
token, i = BlockToken.parse(lines, i)
|
|
71
|
+
children.append(token)
|
|
72
|
+
return Document(children)
|
|
73
|
+
|
|
74
|
+
def render(self, context: Context):
|
|
75
|
+
return "".join([x.render(context) for x in self.children])
|
|
76
|
+
|
|
77
|
+
def metadata(self):
|
|
78
|
+
title = None
|
|
79
|
+
description = None
|
|
80
|
+
context = Context()
|
|
81
|
+
for child in self.children:
|
|
82
|
+
if isinstance(child, Heading) and child.level == 1 and not title:
|
|
83
|
+
title = child.text.render(context)
|
|
84
|
+
if isinstance(child, Paragraph) and not description:
|
|
85
|
+
description = " ".join([x.render(context) for x in child.paragraph_lines])
|
|
86
|
+
if title and description:
|
|
87
|
+
break
|
|
88
|
+
if not title:
|
|
89
|
+
raise Exception("No title found")
|
|
90
|
+
if not description:
|
|
91
|
+
raise Exception("No description found")
|
|
92
|
+
return Metadata(title, description)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class Span(Token):
|
|
97
|
+
children: list[InlineToken]
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def parse(cls, line: str) -> "Span":
|
|
101
|
+
children = []
|
|
102
|
+
i = 0
|
|
103
|
+
while i < len(line):
|
|
104
|
+
token, i = InlineToken.parse(line, i)
|
|
105
|
+
children.append(token)
|
|
106
|
+
return Span(children)
|
|
107
|
+
|
|
108
|
+
def render(self, context: Context):
|
|
109
|
+
return "".join([x.render(context) for x in self.children])
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class Directive(BlockToken):
|
|
114
|
+
directive: str
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def opening_re(cls) -> re.Pattern[str]:
|
|
118
|
+
return re.compile(r"^:([a-zA-Z_][a-zA-Z0-9_]*):$")
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def parse(cls, lines: list[str], i: int) -> tuple[Optional["Directive"], int]:
|
|
122
|
+
match = Directive.opening_re().match(lines[i].strip())
|
|
123
|
+
if not match:
|
|
124
|
+
return None, i
|
|
125
|
+
return Directive(match.group(1)), i + 1
|
|
126
|
+
|
|
127
|
+
def render(self, context: Context):
|
|
128
|
+
func = context.__class__.__dict__.get(self.directive)
|
|
129
|
+
if not inspect.isfunction(func):
|
|
130
|
+
raise Exception(f"Could not find function in context: {self.directive}")
|
|
131
|
+
return getattr(context, self.directive)()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class BlankLine(BlockToken):
|
|
135
|
+
@classmethod
|
|
136
|
+
def parse(cls, lines: list[str], i: int) -> tuple[Optional["BlankLine"], int]:
|
|
137
|
+
return (BlankLine(), i + 1) if lines[i].strip() == "" else (None, i)
|
|
138
|
+
|
|
139
|
+
def render(self, context: Context):
|
|
140
|
+
return ""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class CodeBlock(BlockToken):
|
|
145
|
+
language: Optional[str]
|
|
146
|
+
code_lines: list[str]
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def opening_re(cls) -> re.Pattern[str]:
|
|
150
|
+
return re.compile(r"^(`{3,})([A-Za-z0-9_-]+)?$")
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def closing_re(cls, fence) -> re.Pattern[str]:
|
|
154
|
+
return re.compile(rf"^{fence}$")
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def parse(cls, lines: list[str], i: int) -> tuple[Optional["CodeBlock"], int]:
|
|
158
|
+
match = CodeBlock.opening_re().match(lines[i])
|
|
159
|
+
if not match:
|
|
160
|
+
return None, i
|
|
161
|
+
fence = match.group(1)
|
|
162
|
+
language = match.group(2)
|
|
163
|
+
code_lines = []
|
|
164
|
+
closing_re = CodeBlock.closing_re(fence)
|
|
165
|
+
j = i + 1
|
|
166
|
+
while j < len(lines):
|
|
167
|
+
if closing_re.match(lines[j]):
|
|
168
|
+
return CodeBlock(language, code_lines), j + 1
|
|
169
|
+
code_lines.append(lines[j])
|
|
170
|
+
j += 1
|
|
171
|
+
return None, i
|
|
172
|
+
|
|
173
|
+
def render(self, context: Context):
|
|
174
|
+
code = "\n".join([escape(x, quote=True) for x in self.code_lines])
|
|
175
|
+
if self.language:
|
|
176
|
+
language_class = f' class="language-{escape(self.language, quote=True)}"'
|
|
177
|
+
else:
|
|
178
|
+
language_class = ""
|
|
179
|
+
return f'<pre><code{language_class}>{code}</code></pre>'
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class HorizontalRule(BlockToken):
|
|
183
|
+
@classmethod
|
|
184
|
+
def opening_re(cls) -> re.Pattern[str]:
|
|
185
|
+
return re.compile(r"^\s*---+\s*$")
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def parse(cls, lines: list[str], i: int) -> tuple[Optional["HorizontalRule"], int]:
|
|
189
|
+
return (HorizontalRule(), i + 1) if HorizontalRule.opening_re().match(lines[i]) else (None, i)
|
|
190
|
+
|
|
191
|
+
def render(self, context: Context):
|
|
192
|
+
return "<hr>"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass
|
|
196
|
+
class Heading(BlockToken):
|
|
197
|
+
text: Span
|
|
198
|
+
level: int
|
|
199
|
+
center: bool
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def opening_re(cls) -> re.Pattern[str]:
|
|
203
|
+
return re.compile(r"^(#{1,6})\s+(.+?)\s*( #{,6})?$")
|
|
204
|
+
|
|
205
|
+
@classmethod
|
|
206
|
+
def parse(cls, lines: list[str], i: int) -> tuple[Optional["Heading"], int]:
|
|
207
|
+
match = Heading.opening_re().match(lines[i])
|
|
208
|
+
if not match:
|
|
209
|
+
return None, i
|
|
210
|
+
text = Span.parse(match.group(2).strip())
|
|
211
|
+
level = len(match.group(1))
|
|
212
|
+
center = ((match.group(3) or "").strip() == match.group(1))
|
|
213
|
+
return Heading(text, level, center), i + 1
|
|
214
|
+
|
|
215
|
+
def render(self, context: Context):
|
|
216
|
+
cls = ' class="center"' if self.center else ""
|
|
217
|
+
return f"<h{self.level}{cls}>{self.text.render(context)}</h{self.level}>"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@dataclass
|
|
221
|
+
class List(BlockToken):
|
|
222
|
+
ordered: bool
|
|
223
|
+
items: list[Document]
|
|
224
|
+
|
|
225
|
+
@classmethod
|
|
226
|
+
def opening_re(cls) -> re.Pattern[str]:
|
|
227
|
+
return re.compile(r"^( [-#] )(.*)$")
|
|
228
|
+
|
|
229
|
+
@classmethod
|
|
230
|
+
def parse(cls, lines: list[str], i: int) -> tuple[Optional["List"], int]:
|
|
231
|
+
match = List.opening_re().match(lines[i])
|
|
232
|
+
if not match:
|
|
233
|
+
return None, i
|
|
234
|
+
marker = match.group(1)
|
|
235
|
+
inner_lines = [match.group(2)]
|
|
236
|
+
items = []
|
|
237
|
+
j = i + 1
|
|
238
|
+
while j < len(lines):
|
|
239
|
+
if lines[j].strip() == "" or lines[j][:3] == " ":
|
|
240
|
+
inner_lines.append(lines[j][3:])
|
|
241
|
+
j += 1
|
|
242
|
+
elif lines[j][:3] == marker:
|
|
243
|
+
items.append(Document.parse(inner_lines))
|
|
244
|
+
inner_lines = [lines[j][3:]]
|
|
245
|
+
j += 1
|
|
246
|
+
else:
|
|
247
|
+
break
|
|
248
|
+
items.append(Document.parse(inner_lines))
|
|
249
|
+
return List("#" in marker, items), j
|
|
250
|
+
|
|
251
|
+
def render(self, context: Context):
|
|
252
|
+
tag = "ol" if self.ordered else "ul"
|
|
253
|
+
items = "".join([f"<li>{item.render(context)}</li>" for item in self.items])
|
|
254
|
+
return f"<{tag}>{items}</{tag}>"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass
|
|
258
|
+
class BlockQuote(BlockToken):
|
|
259
|
+
document: Document
|
|
260
|
+
|
|
261
|
+
@classmethod
|
|
262
|
+
def parse(cls, lines: list[str], i: int) -> tuple[Optional["BlockQuote"], int]:
|
|
263
|
+
if not lines[i].startswith("> "):
|
|
264
|
+
return None, i
|
|
265
|
+
j = i + 1
|
|
266
|
+
while j < len(lines) and lines[j].startswith("> "):
|
|
267
|
+
j += 1
|
|
268
|
+
return BlockQuote(Document.parse([line[2:] for line in lines[i:j]])), j
|
|
269
|
+
|
|
270
|
+
def render(self, context: Context):
|
|
271
|
+
return f"<blockquote>{self.document.render(context)}</blockquote>"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclass
|
|
275
|
+
class Paragraph(BlockToken):
|
|
276
|
+
paragraph_lines: list[Span]
|
|
277
|
+
align: str
|
|
278
|
+
|
|
279
|
+
@classmethod
|
|
280
|
+
def is_other_block_token(_, lines: list[str], i: int) -> bool:
|
|
281
|
+
for cls in BlockToken.subclasses:
|
|
282
|
+
if cls != Paragraph:
|
|
283
|
+
token, i = cls.parse(lines, i)
|
|
284
|
+
if token:
|
|
285
|
+
return True
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
@classmethod
|
|
289
|
+
def parse(cls, lines: list[str], i: int) -> tuple[Optional["Paragraph"], int]:
|
|
290
|
+
alignments = ("center", "left", "right")
|
|
291
|
+
align = ""
|
|
292
|
+
paragraph_lines = []
|
|
293
|
+
j = i
|
|
294
|
+
while j < len(lines):
|
|
295
|
+
if Paragraph.is_other_block_token(lines, j):
|
|
296
|
+
break
|
|
297
|
+
line = lines[j].strip()
|
|
298
|
+
if i == j:
|
|
299
|
+
for alignment in alignments:
|
|
300
|
+
marker = f"{alignment}:"
|
|
301
|
+
if line.startswith(marker):
|
|
302
|
+
align = alignment
|
|
303
|
+
line = line[len(marker):].strip()
|
|
304
|
+
break
|
|
305
|
+
paragraph_lines.append(Span.parse(line))
|
|
306
|
+
j += 1
|
|
307
|
+
return (Paragraph(paragraph_lines, align), j) if paragraph_lines else (None, i)
|
|
308
|
+
|
|
309
|
+
def render(self, context: Context):
|
|
310
|
+
text = " ".join([x.render(context) for x in self.paragraph_lines])
|
|
311
|
+
align_class = (f' class="{self.align}"' if self.align else "")
|
|
312
|
+
return f'<p{align_class}>{text}</p>'
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@dataclass
|
|
316
|
+
class Bold(InlineToken):
|
|
317
|
+
text: Span
|
|
318
|
+
|
|
319
|
+
@classmethod
|
|
320
|
+
def opening_re(cls) -> re.Pattern[str]:
|
|
321
|
+
return re.compile(r"^\*[A-Za-z0-9]$")
|
|
322
|
+
|
|
323
|
+
@classmethod
|
|
324
|
+
def closing_re(cls) -> re.Pattern[str]:
|
|
325
|
+
return re.compile(r"^[A-Za-z0-9]\*$")
|
|
326
|
+
|
|
327
|
+
@classmethod
|
|
328
|
+
def parse(cls, line: str, i: int) -> tuple[Optional["Bold"], int]:
|
|
329
|
+
if not Bold.opening_re().match(line[i:i+2]):
|
|
330
|
+
return None, i
|
|
331
|
+
closing_re = Bold.closing_re()
|
|
332
|
+
j = i + 1
|
|
333
|
+
while j + 1 < len(line):
|
|
334
|
+
if closing_re.match(line[j:j+2]):
|
|
335
|
+
return Bold(Span.parse(line[i+1:j+1])), j + 2
|
|
336
|
+
j += 1
|
|
337
|
+
return None, i
|
|
338
|
+
|
|
339
|
+
def render(self, context: Context):
|
|
340
|
+
return f"<strong>{self.text.render(context)}</strong>"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@dataclass
|
|
344
|
+
class InlineCode(InlineToken):
|
|
345
|
+
code: str
|
|
346
|
+
|
|
347
|
+
@classmethod
|
|
348
|
+
def is_fence(cls, line: str, start: int, fence_length: int) -> bool:
|
|
349
|
+
end = start + fence_length
|
|
350
|
+
return line[start:end] == "`" * fence_length
|
|
351
|
+
|
|
352
|
+
@classmethod
|
|
353
|
+
def parse(cls, line: str, i: int) -> tuple[Optional["InlineCode"], int]:
|
|
354
|
+
fence_length = 0
|
|
355
|
+
while InlineCode.is_fence(line, i, fence_length + 1):
|
|
356
|
+
fence_length += 1
|
|
357
|
+
if fence_length == 0:
|
|
358
|
+
return None, i
|
|
359
|
+
j = i + fence_length
|
|
360
|
+
while j + fence_length <= len(line):
|
|
361
|
+
if InlineCode.is_fence(line, j, fence_length):
|
|
362
|
+
start = i + fence_length
|
|
363
|
+
return InlineCode(line[start:j]), j + fence_length
|
|
364
|
+
j += 1
|
|
365
|
+
return None, i
|
|
366
|
+
|
|
367
|
+
def render(self, context: Context):
|
|
368
|
+
return f"<code>{escape(self.code, quote=True)}</code>"
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@dataclass
|
|
372
|
+
class Link(InlineToken):
|
|
373
|
+
text: Span
|
|
374
|
+
href: str
|
|
375
|
+
button: bool
|
|
376
|
+
|
|
377
|
+
@classmethod
|
|
378
|
+
def opening_re(cls) -> re.Pattern[str]:
|
|
379
|
+
return re.compile(r"^([\[{][^}\]]+[}\]])\(([^)]+)\)")
|
|
380
|
+
|
|
381
|
+
@classmethod
|
|
382
|
+
def parse(cls, line: str, i: int) -> tuple[Optional["Link"], int]:
|
|
383
|
+
match = Link.opening_re().match(line[i:])
|
|
384
|
+
if not match:
|
|
385
|
+
return None, i
|
|
386
|
+
text = match.group(1)
|
|
387
|
+
if (text[0] == '[') != (text[-1] == ']'):
|
|
388
|
+
return None, i
|
|
389
|
+
button = (text[0] == '{')
|
|
390
|
+
text = Span.parse(text[1:-1].strip())
|
|
391
|
+
href = match.group(2)
|
|
392
|
+
end = i + len(match.group(0))
|
|
393
|
+
return Link(text, href, button), end
|
|
394
|
+
|
|
395
|
+
def render(self, context: Context):
|
|
396
|
+
if not is_safe_href(self.href):
|
|
397
|
+
raise Exception(f"Unsafe href: {self.href}")
|
|
398
|
+
button_class = (' class="button"' if self.button else "")
|
|
399
|
+
return f'<a href="{escape(self.href, quote=True)}"{button_class}>{self.text.render(context)}</a>'
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@dataclass
|
|
403
|
+
class URL(InlineToken):
|
|
404
|
+
href: str
|
|
405
|
+
|
|
406
|
+
@classmethod
|
|
407
|
+
def opening_re(cls) -> re.Pattern[str]:
|
|
408
|
+
return re.compile(r"^https?://[^\s<>\[\]()]+")
|
|
409
|
+
|
|
410
|
+
@classmethod
|
|
411
|
+
def parse(cls, line: str, i: int) -> tuple[Optional["URL"], int]:
|
|
412
|
+
match = URL.opening_re().match(line[i:])
|
|
413
|
+
if not match:
|
|
414
|
+
return None, i
|
|
415
|
+
href = match.group(0)
|
|
416
|
+
return URL(href), i + len(href)
|
|
417
|
+
|
|
418
|
+
def render(self, context: Context):
|
|
419
|
+
if not is_safe_href(self.href):
|
|
420
|
+
raise Exception(f"Unsafe href: {self.href}")
|
|
421
|
+
href = escape(self.href, quote=True)
|
|
422
|
+
return f'<a href="{href}">{href}</a>'
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@dataclass
|
|
426
|
+
class Text(InlineToken):
|
|
427
|
+
value: str
|
|
428
|
+
|
|
429
|
+
@classmethod
|
|
430
|
+
def is_other_inline_token(_, line: str, i: int) -> bool:
|
|
431
|
+
for cls in InlineToken.subclasses:
|
|
432
|
+
if cls != Text:
|
|
433
|
+
token, i = cls.parse(line, i)
|
|
434
|
+
if token:
|
|
435
|
+
return True
|
|
436
|
+
return False
|
|
437
|
+
|
|
438
|
+
@classmethod
|
|
439
|
+
def parse(cls, line: str, i: int) -> tuple[Optional["Text"], int]:
|
|
440
|
+
value = ""
|
|
441
|
+
j = i
|
|
442
|
+
while j < len(line):
|
|
443
|
+
if Text.is_other_inline_token(line, j):
|
|
444
|
+
break
|
|
445
|
+
value += line[j]
|
|
446
|
+
j += 1
|
|
447
|
+
return (Text(value), j) if value else (None, i)
|
|
448
|
+
|
|
449
|
+
def render(self, context: Context):
|
|
450
|
+
value = escape(self.value, quote=True)
|
|
451
|
+
return value
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def metadata(source: str, context: Context = Context()) -> Metadata:
|
|
455
|
+
lines = source.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
456
|
+
return Document.parse(lines).metadata()
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def render(source: str, context: Context = Context()) -> str:
|
|
460
|
+
lines = source.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
461
|
+
return Document.parse(lines).render(context)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def is_safe_href(href: str) -> bool:
|
|
465
|
+
href = href.strip()
|
|
466
|
+
parsed = urlparse(href)
|
|
467
|
+
return (not parsed.scheme) or (parsed.scheme.lower() in SAFE_SCHEMES)
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #060816;
|
|
3
|
+
--bg-2: #0b1224;
|
|
4
|
+
--fg: #e6eefc;
|
|
5
|
+
--muted: #8fa2c9;
|
|
6
|
+
--border: rgba(122, 162, 255, 0.2);
|
|
7
|
+
--surface: rgba(12, 18, 36, 0.74);
|
|
8
|
+
--surface-2: rgba(21, 29, 56, 0.92);
|
|
9
|
+
--surface-3: rgba(79, 124, 255, 0.12);
|
|
10
|
+
--link: #7dd3fc;
|
|
11
|
+
--link-hover: #c084fc;
|
|
12
|
+
--glow: rgba(96, 165, 250, 0.22);
|
|
13
|
+
--code-bg: #0a1022;
|
|
14
|
+
--code-fg: #dbeafe;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
* { box-sizing: border-box; }
|
|
18
|
+
|
|
19
|
+
body {
|
|
20
|
+
margin: 0;
|
|
21
|
+
min-height: 100vh;
|
|
22
|
+
background: var(--bg);
|
|
23
|
+
color: var(--fg);
|
|
24
|
+
font: 1.05rem/1.75 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
25
|
+
background-image:
|
|
26
|
+
radial-gradient(circle at top left, rgba(56, 189, 248, 0.14), transparent 30rem),
|
|
27
|
+
radial-gradient(circle at top right, rgba(168, 85, 247, 0.14), transparent 24rem),
|
|
28
|
+
linear-gradient(180deg, #0a1022 0%, #060816 100%);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.site-shell {
|
|
32
|
+
position: relative;
|
|
33
|
+
max-width: 86rem;
|
|
34
|
+
margin: 0 auto;
|
|
35
|
+
padding: 3rem 1.25rem 4rem;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.site-frame {
|
|
39
|
+
position: relative;
|
|
40
|
+
max-width: 72ch;
|
|
41
|
+
margin: 0 auto;
|
|
42
|
+
border: 1px solid var(--border);
|
|
43
|
+
border-radius: 1.4rem;
|
|
44
|
+
background: linear-gradient(180deg, rgba(12, 18, 36, 0.9), rgba(8, 12, 26, 0.92));
|
|
45
|
+
box-shadow:
|
|
46
|
+
0 0 0 1px rgba(255, 255, 255, 0.03) inset,
|
|
47
|
+
0 30px 80px rgba(2, 8, 23, 0.65),
|
|
48
|
+
0 0 40px var(--glow);
|
|
49
|
+
backdrop-filter: blur(18px);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.site-content {
|
|
53
|
+
padding: 0.6rem 1.6rem 2rem;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.page-glow {
|
|
57
|
+
position: fixed;
|
|
58
|
+
z-index: 0;
|
|
59
|
+
pointer-events: none;
|
|
60
|
+
border-radius: 999px;
|
|
61
|
+
filter: blur(90px);
|
|
62
|
+
opacity: 0.7;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.page-glow-1 {
|
|
66
|
+
top: 3rem;
|
|
67
|
+
left: 2rem;
|
|
68
|
+
width: 18rem;
|
|
69
|
+
height: 18rem;
|
|
70
|
+
background: rgba(34, 211, 238, 0.18);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.page-glow-2 {
|
|
74
|
+
right: 4rem;
|
|
75
|
+
bottom: 3rem;
|
|
76
|
+
width: 20rem;
|
|
77
|
+
height: 20rem;
|
|
78
|
+
background: rgba(168, 85, 247, 0.16);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
h1, h2, h3, h4, h5, h6 {
|
|
82
|
+
margin: 2rem 0 0.75rem;
|
|
83
|
+
line-height: 1.2;
|
|
84
|
+
letter-spacing: -0.03em;
|
|
85
|
+
color: #f8fbff;
|
|
86
|
+
text-shadow: 0 0 22px rgba(125, 211, 252, 0.08);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
h1 { margin-top: 0; font-size: clamp(2.4rem, 6vw, 3.4rem); }
|
|
90
|
+
h2 { font-size: 1.85rem; }
|
|
91
|
+
h3 { font-size: 1.4rem; }
|
|
92
|
+
|
|
93
|
+
p, ul, ol, pre, blockquote, table, hr { margin: 1.1rem 0; }
|
|
94
|
+
ul, ol { padding-left: 1.4rem; }
|
|
95
|
+
li + li { margin-top: 0.35rem; }
|
|
96
|
+
li > p { margin: 0.3rem 0; }
|
|
97
|
+
|
|
98
|
+
a, a:visited {
|
|
99
|
+
color: var(--link);
|
|
100
|
+
text-underline-offset: 0.16em;
|
|
101
|
+
text-decoration-thickness: 0.08em;
|
|
102
|
+
transition: color 140ms ease, text-shadow 140ms ease, border-color 140ms ease, background 140ms ease, transform 140ms ease;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
a:hover {
|
|
106
|
+
color: var(--link-hover);
|
|
107
|
+
text-shadow: 0 0 16px rgba(192, 132, 252, 0.28);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
a.button, a.button:visited {
|
|
111
|
+
display: inline-block;
|
|
112
|
+
margin: 0.25rem 0.35rem 0.25rem 0;
|
|
113
|
+
padding: 0.55rem 0.85rem;
|
|
114
|
+
border: 1px solid rgba(125, 211, 252, 0.3);
|
|
115
|
+
border-radius: 0.8rem;
|
|
116
|
+
background: linear-gradient(180deg, rgba(17, 24, 39, 0.96), rgba(13, 20, 38, 0.92));
|
|
117
|
+
color: #dbeafe;
|
|
118
|
+
font-weight: 600;
|
|
119
|
+
text-decoration: none;
|
|
120
|
+
box-shadow:
|
|
121
|
+
0 0 0 1px rgba(255, 255, 255, 0.03) inset,
|
|
122
|
+
0 10px 24px rgba(2, 8, 23, 0.35);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
a.button:hover {
|
|
126
|
+
background: linear-gradient(180deg, rgba(25, 35, 63, 0.98), rgba(16, 24, 46, 0.95));
|
|
127
|
+
text-decoration: none;
|
|
128
|
+
transform: translateY(-1px);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
a.button:active {
|
|
132
|
+
transform: translateY(1px);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
a.button:focus-visible {
|
|
136
|
+
outline: 2px solid rgba(125, 211, 252, 0.5);
|
|
137
|
+
outline-offset: 2px;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
code {
|
|
141
|
+
padding: 0.12rem 0.35rem;
|
|
142
|
+
border-radius: 0.35rem;
|
|
143
|
+
background: rgba(125, 211, 252, 0.1);
|
|
144
|
+
color: #bfdbfe;
|
|
145
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
146
|
+
font-size: 0.92em;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
pre {
|
|
150
|
+
overflow-x: auto;
|
|
151
|
+
padding: 1rem 1.1rem;
|
|
152
|
+
border-radius: 0.9rem;
|
|
153
|
+
border: 1px solid rgba(125, 211, 252, 0.12);
|
|
154
|
+
background:
|
|
155
|
+
linear-gradient(180deg, rgba(14, 21, 42, 0.98), rgba(7, 12, 24, 0.98));
|
|
156
|
+
color: var(--code-fg);
|
|
157
|
+
box-shadow:
|
|
158
|
+
0 16px 40px rgba(2, 8, 23, 0.42),
|
|
159
|
+
0 0 24px rgba(96, 165, 250, 0.08);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
pre code {
|
|
163
|
+
padding: 0;
|
|
164
|
+
background: transparent;
|
|
165
|
+
color: inherit;
|
|
166
|
+
font-size: 0.95rem;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
blockquote {
|
|
170
|
+
padding: 0.2rem 0 0.2rem 1rem;
|
|
171
|
+
border-left: 0.28rem solid #7dd3fc;
|
|
172
|
+
border-radius: 0 1rem 1rem 0;
|
|
173
|
+
background: rgba(125, 211, 252, 0.06);
|
|
174
|
+
color: #c7d2fe;
|
|
175
|
+
box-shadow: inset 0 0 0 1px rgba(125, 211, 252, 0.08);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
blockquote > :first-child { margin-top: 0.6rem; }
|
|
179
|
+
blockquote > :last-child { margin-bottom: 0.6rem; }
|
|
180
|
+
|
|
181
|
+
table {
|
|
182
|
+
width: 100%;
|
|
183
|
+
border-collapse: collapse;
|
|
184
|
+
overflow: hidden;
|
|
185
|
+
border: 1px solid rgba(125, 211, 252, 0.14);
|
|
186
|
+
border-radius: 1rem;
|
|
187
|
+
background: rgba(12, 18, 36, 0.55);
|
|
188
|
+
box-shadow: 0 18px 42px rgba(2, 8, 23, 0.32);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
td, th {
|
|
192
|
+
padding: 0.65rem 0.75rem;
|
|
193
|
+
text-align: left;
|
|
194
|
+
vertical-align: top;
|
|
195
|
+
border-bottom: 1px solid var(--border);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
th {
|
|
199
|
+
background: linear-gradient(180deg, rgba(125, 211, 252, 0.12), rgba(96, 165, 250, 0.06));
|
|
200
|
+
color: #e0f2fe;
|
|
201
|
+
font-weight: 650;
|
|
202
|
+
text-transform: uppercase;
|
|
203
|
+
letter-spacing: 0.08em;
|
|
204
|
+
font-size: 0.78rem;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
td:first-child { width: 34%; font-weight: 600; }
|
|
208
|
+
|
|
209
|
+
hr {
|
|
210
|
+
border: 0;
|
|
211
|
+
border-top: 1px solid rgba(125, 211, 252, 0.16);
|
|
212
|
+
box-shadow: 0 0 16px rgba(125, 211, 252, 0.12);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
img {
|
|
216
|
+
display: block;
|
|
217
|
+
max-width: 100%;
|
|
218
|
+
height: auto;
|
|
219
|
+
margin: 1.5rem auto;
|
|
220
|
+
border: 1px solid rgba(125, 211, 252, 0.16);
|
|
221
|
+
border-radius: 1rem;
|
|
222
|
+
box-shadow:
|
|
223
|
+
0 20px 42px rgba(2, 8, 23, 0.4),
|
|
224
|
+
0 0 30px rgba(96, 165, 250, 0.08);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.breadcrumbs {
|
|
228
|
+
margin-bottom: 1.5rem;
|
|
229
|
+
color: var(--muted);
|
|
230
|
+
font-size: 0.82rem;
|
|
231
|
+
letter-spacing: 0.08em;
|
|
232
|
+
text-transform: uppercase;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.breadcrumbs a, .breadcrumbs a:visited {
|
|
236
|
+
color: #c4b5fd;
|
|
237
|
+
text-decoration: none;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.breadcrumbs a:hover {
|
|
241
|
+
color: #e9d5ff;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.center { text-align: center; }
|
|
245
|
+
.left { text-align: left; }
|
|
246
|
+
.right { text-align: right; }
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>TITLE</title>
|
|
7
|
+
<meta name="color-scheme" content="dark">
|
|
8
|
+
<link rel="stylesheet" href="/styles.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div class="page-glow page-glow-1"></div>
|
|
12
|
+
<div class="page-glow page-glow-2"></div>
|
|
13
|
+
<main class="site-shell">
|
|
14
|
+
<div class="site-frame">
|
|
15
|
+
<article class="site-content">
|
|
16
|
+
MARKUP
|
|
17
|
+
</article>
|
|
18
|
+
</div>
|
|
19
|
+
</main>
|
|
20
|
+
</body>
|
|
21
|
+
</html>
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
from textwrap import dedent
|
|
2
|
+
|
|
3
|
+
from .mymarkup import render
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_headings():
|
|
7
|
+
assert render("# Heading 1") == "<h1>Heading 1</h1>"
|
|
8
|
+
assert render("## Heading 2") == "<h2>Heading 2</h2>"
|
|
9
|
+
assert render("### Heading 3") == "<h3>Heading 3</h3>"
|
|
10
|
+
|
|
11
|
+
assert render("# Heading 1 #") == '<h1 class="center">Heading 1</h1>'
|
|
12
|
+
assert render("## Heading 2 ##") == '<h2 class="center">Heading 2</h2>'
|
|
13
|
+
assert render("### Heading 3 ###") == '<h3 class="center">Heading 3</h3>'
|
|
14
|
+
|
|
15
|
+
assert render("#Not a heading") == "<p>#Not a heading</p>"
|
|
16
|
+
|
|
17
|
+
assert render("# Heading with *bold*") == (
|
|
18
|
+
"<h1>Heading with <strong>bold</strong></h1>"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
assert render("# Heading with `code`") == (
|
|
22
|
+
"<h1>Heading with <code>code</code></h1>"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
assert render("# Heading with [link](https://example.com)") == (
|
|
26
|
+
'<h1>Heading with <a href="https://example.com">link</a></h1>'
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_paragraphs():
|
|
31
|
+
assert render(
|
|
32
|
+
dedent("""
|
|
33
|
+
Line one
|
|
34
|
+
Line two
|
|
35
|
+
""").strip()
|
|
36
|
+
) == "<p>Line one Line two</p>"
|
|
37
|
+
|
|
38
|
+
assert render(
|
|
39
|
+
dedent("""
|
|
40
|
+
First paragraph
|
|
41
|
+
|
|
42
|
+
Second paragraph
|
|
43
|
+
""").strip()
|
|
44
|
+
) == "<p>First paragraph</p><p>Second paragraph</p>"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_lists():
|
|
48
|
+
assert render(" - item 1\n - item 2") == "<ul><li><p>item 1</p></li><li><p>item 2</p></li></ul>"
|
|
49
|
+
|
|
50
|
+
assert render(" # First\n # Second\n # Third") == "<ol><li><p>First</p></li><li><p>Second</p></li><li><p>Third</p></li></ol>"
|
|
51
|
+
|
|
52
|
+
assert render(" item\n\n #item") == "<p>item</p><p>#item</p>"
|
|
53
|
+
|
|
54
|
+
assert render(" - item with *bold*") == (
|
|
55
|
+
"<ul><li><p>item with <strong>bold</strong></p></li></ul>"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
assert render(" - item with `code`") == (
|
|
59
|
+
"<ul><li><p>item with <code>code</code></p></li></ul>"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
assert render(" - item with [link](https://example.com)") == (
|
|
63
|
+
'<ul><li><p>item with <a href="https://example.com">link</a></p></li></ul>'
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_nested_lists():
|
|
68
|
+
assert render(
|
|
69
|
+
dedent("""
|
|
70
|
+
test
|
|
71
|
+
- Parent
|
|
72
|
+
- Child
|
|
73
|
+
- Grandchild
|
|
74
|
+
""").strip()
|
|
75
|
+
) == (
|
|
76
|
+
"<p>test</p><ul><li><p>Parent</p>"
|
|
77
|
+
"<ul><li><p>Child</p>"
|
|
78
|
+
"<ul><li><p>Grandchild</p></li></ul>"
|
|
79
|
+
"</li></ul>"
|
|
80
|
+
"</li></ul>"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_mixed_lists():
|
|
85
|
+
assert render(
|
|
86
|
+
dedent("""
|
|
87
|
+
test
|
|
88
|
+
- Item
|
|
89
|
+
# Step one
|
|
90
|
+
# Step two
|
|
91
|
+
- Item two
|
|
92
|
+
""").strip()
|
|
93
|
+
) == (
|
|
94
|
+
"<p>test</p><ul><li><p>Item</p>"
|
|
95
|
+
"<ol><li><p>Step one</p></li><li><p>Step two</p></li></ol>"
|
|
96
|
+
"</li><li><p>Item two</p></li></ul>"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_list_item_continuation():
|
|
101
|
+
assert render(
|
|
102
|
+
dedent("""
|
|
103
|
+
test
|
|
104
|
+
- This is a list item
|
|
105
|
+
continued here
|
|
106
|
+
and continued here
|
|
107
|
+
- and this is the next item
|
|
108
|
+
""").strip()
|
|
109
|
+
) == "<p>test</p><ul><li><p>This is a list item continued here and continued here</p></li><li><p>and this is the next item</p></li></ul>"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_inline_markup():
|
|
113
|
+
assert render("*bold*") == "<p><strong>bold</strong></p>"
|
|
114
|
+
|
|
115
|
+
assert render("*bold and `code` inside*") == (
|
|
116
|
+
"<p><strong>bold and <code>code</code> inside</strong></p>"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
assert render("`*not bold* [not a link]`") == (
|
|
120
|
+
"<p><code>*not bold* [not a link]</code></p>"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
assert render("word*bold*") == "<p>word<strong>bold</strong></p>"
|
|
124
|
+
assert render("*bold*word") == "<p><strong>bold</strong>word</p>"
|
|
125
|
+
assert render("word*bold*word") == "<p>word<strong>bold</strong>word</p>"
|
|
126
|
+
|
|
127
|
+
assert render("word *bold*") == "<p>word <strong>bold</strong></p>"
|
|
128
|
+
assert render("*bold* word") == "<p><strong>bold</strong> word</p>"
|
|
129
|
+
assert render("word *bold* word") == (
|
|
130
|
+
"<p>word <strong>bold</strong> word</p>"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
assert render("*") == "<p>*</p>"
|
|
134
|
+
assert render("**") == "<p>**</p>"
|
|
135
|
+
assert render("***") == "<p>***</p>"
|
|
136
|
+
assert render("* *") == "<p>* *</p>"
|
|
137
|
+
|
|
138
|
+
assert render("*bold `code` [link](link.html) test*") == (
|
|
139
|
+
'<p><strong>bold <code>code</code> '
|
|
140
|
+
'<a href="link.html">link</a> test</strong></p>'
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
assert render("[*bold link*](https://example.com)") == (
|
|
144
|
+
'<p><a href="https://example.com">'
|
|
145
|
+
'<strong>bold link</strong>'
|
|
146
|
+
"</a></p>"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
assert render("[`code link`](https://example.com)") == (
|
|
150
|
+
'<p><a href="https://example.com">'
|
|
151
|
+
"<code>code link</code>"
|
|
152
|
+
"</a></p>"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
assert render("[link](link.html) and *bold* and `code`") == (
|
|
156
|
+
'<p><a href="link.html">link</a> '
|
|
157
|
+
"and <strong>bold</strong> "
|
|
158
|
+
"and <code>code</code></p>"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
assert render("*bold.*") == "<p>*bold.*</p>"
|
|
162
|
+
assert render("(*bold*)") == "<p>(<strong>bold</strong>)</p>"
|
|
163
|
+
assert render("word (*bold*) word") == (
|
|
164
|
+
"<p>word (<strong>bold</strong>) word</p>"
|
|
165
|
+
)
|
|
166
|
+
assert render("*bold*,") == "<p><strong>bold</strong>,</p>"
|
|
167
|
+
assert render("*bold*!") == "<p><strong>bold</strong>!</p>"
|
|
168
|
+
assert render("*bold*?") == "<p><strong>bold</strong>?</p>"
|
|
169
|
+
assert render("*bold*.") == "<p><strong>bold</strong>.</p>"
|
|
170
|
+
|
|
171
|
+
assert render("word*bold*.") == "<p>word<strong>bold</strong>.</p>"
|
|
172
|
+
assert render("(*bold*word)") == "<p>(<strong>bold</strong>word)</p>"
|
|
173
|
+
assert render("(word*bold*)") == "<p>(word<strong>bold</strong>)</p>"
|
|
174
|
+
|
|
175
|
+
assert render("Use `foo_bar()` here") == (
|
|
176
|
+
"<p>Use <code>foo_bar()</code> here</p>"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
assert render("Call `obj.method()`.") == (
|
|
180
|
+
"<p>Call <code>obj.method()</code>.</p>"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
assert render("prefix`code`suffix") == (
|
|
184
|
+
"<p>prefix<code>code</code>suffix</p>"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
assert render("`not closed") == "<p>`not closed</p>"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_empty_or_invalid_inline_markup():
|
|
191
|
+
assert render("**") == "<p>**</p>"
|
|
192
|
+
assert render("``") == "<p>``</p>"
|
|
193
|
+
assert render("[link]()") == "<p>[link]()</p>"
|
|
194
|
+
assert render("*not bold") == "<p>*not bold</p>"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_regular_links():
|
|
198
|
+
assert render("[Example](https://example.com)") == (
|
|
199
|
+
'<p><a href="https://example.com">Example</a></p>'
|
|
200
|
+
)
|
|
201
|
+
assert render("[Wiki page](wiki-page.html)") == (
|
|
202
|
+
'<p><a href="wiki-page.html">Wiki page</a></p>'
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_bare_urls():
|
|
207
|
+
assert render(
|
|
208
|
+
dedent("""
|
|
209
|
+
https://example.com
|
|
210
|
+
""").strip()
|
|
211
|
+
) == (
|
|
212
|
+
'<p><a href="https://example.com">'
|
|
213
|
+
"https://example.com"
|
|
214
|
+
"</a></p>"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_images():
|
|
219
|
+
assert render("[https://example.com/image.png]") == (
|
|
220
|
+
'<p><img src="https://example.com/image.png"></p>'
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
assert render("[/images/cat.webp]") == (
|
|
224
|
+
'<p><img src="/images/cat.webp"></p>'
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
assert render("[cat.svg]") == '<p><img src="cat.svg"></p>'
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_fenced_code_blocks():
|
|
231
|
+
assert render(
|
|
232
|
+
dedent("""
|
|
233
|
+
```
|
|
234
|
+
print("hello")
|
|
235
|
+
```
|
|
236
|
+
""").strip()
|
|
237
|
+
) == dedent("""
|
|
238
|
+
<pre><code>print("hello")</code></pre>
|
|
239
|
+
""").strip()
|
|
240
|
+
|
|
241
|
+
assert render(
|
|
242
|
+
dedent("""
|
|
243
|
+
```python
|
|
244
|
+
print("hello")
|
|
245
|
+
print("hello")
|
|
246
|
+
```
|
|
247
|
+
""").strip()
|
|
248
|
+
) == dedent("""
|
|
249
|
+
<pre><code class="language-python">print("hello")
|
|
250
|
+
print("hello")</code></pre>
|
|
251
|
+
""").strip()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_variable_length_code_fences():
|
|
255
|
+
assert render(
|
|
256
|
+
dedent("""
|
|
257
|
+
````markdown
|
|
258
|
+
```
|
|
259
|
+
nested code fence
|
|
260
|
+
```
|
|
261
|
+
````
|
|
262
|
+
""").strip()
|
|
263
|
+
) == dedent("""
|
|
264
|
+
<pre><code class="language-markdown">```
|
|
265
|
+
nested code fence
|
|
266
|
+
```</code></pre>
|
|
267
|
+
""").strip()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_horizontal_rules():
|
|
271
|
+
assert render("---") == "<hr>"
|
|
272
|
+
assert render("-----") == "<hr>"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_blockquotes():
|
|
276
|
+
assert render("> quoted text") == (
|
|
277
|
+
"<blockquote><p>quoted text</p></blockquote>"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
assert render(
|
|
281
|
+
dedent("""
|
|
282
|
+
> quoted line one
|
|
283
|
+
> quoted line two
|
|
284
|
+
""").strip()
|
|
285
|
+
) == (
|
|
286
|
+
"<blockquote><p>"
|
|
287
|
+
"quoted line one quoted line two"
|
|
288
|
+
"</p></blockquote>"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def test_xss():
|
|
293
|
+
assert render("<script>alert(1)</script>") == (
|
|
294
|
+
"<p><script>alert(1)</script></p>"
|
|
295
|
+
)
|
|
296
|
+
assert render('[x](" onclick="alert(1)') == '<p><a href="" onclick="alert(1">x</a></p>'
|
|
297
|
+
assert render("[example](https://example.com?q=<script>)") == (
|
|
298
|
+
'<p><a href="https://example.com?q=<script>">example</a></p>'
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def main():
|
|
303
|
+
test_headings()
|
|
304
|
+
test_paragraphs()
|
|
305
|
+
test_lists()
|
|
306
|
+
test_nested_lists()
|
|
307
|
+
test_mixed_lists()
|
|
308
|
+
test_list_item_continuation()
|
|
309
|
+
test_inline_markup()
|
|
310
|
+
test_empty_or_invalid_inline_markup()
|
|
311
|
+
test_regular_links()
|
|
312
|
+
test_bare_urls()
|
|
313
|
+
# test_images()
|
|
314
|
+
test_fenced_code_blocks()
|
|
315
|
+
test_variable_length_code_fences()
|
|
316
|
+
test_horizontal_rules()
|
|
317
|
+
test_blockquotes()
|
|
318
|
+
test_xss()
|
|
319
|
+
|
|
320
|
+
print("All tests passed")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
if __name__ == "__main__":
|
|
324
|
+
main()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
src/mymarkup/__init__.py
|
|
3
|
+
src/mymarkup/__main__.py
|
|
4
|
+
src/mymarkup/mymarkup.py
|
|
5
|
+
src/mymarkup/styles.css
|
|
6
|
+
src/mymarkup/template.html
|
|
7
|
+
src/mymarkup/tests.py
|
|
8
|
+
src/mymarkup.egg-info/PKG-INFO
|
|
9
|
+
src/mymarkup.egg-info/SOURCES.txt
|
|
10
|
+
src/mymarkup.egg-info/dependency_links.txt
|
|
11
|
+
src/mymarkup.egg-info/entry_points.txt
|
|
12
|
+
src/mymarkup.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mymarkup
|