mymarkup 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mymarkup/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from .mymarkup import Context, render, metadata
mymarkup/__main__.py ADDED
@@ -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())
mymarkup/mymarkup.py ADDED
@@ -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)
mymarkup/styles.css ADDED
@@ -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; }
mymarkup/template.html ADDED
@@ -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>
mymarkup/tests.py ADDED
@@ -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(&quot;hello&quot;)</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(&quot;hello&quot;)
250
+ print(&quot;hello&quot;)</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>&lt;script&gt;alert(1)&lt;/script&gt;</p>"
295
+ )
296
+ assert render('[x](" onclick="alert(1)') == '<p><a href="&quot; onclick=&quot;alert(1">x</a></p>'
297
+ assert render("[example](https://example.com?q=<script>)") == (
298
+ '<p><a href="https://example.com?q=&lt;script&gt;">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,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: mymarkup
3
+ Version: 0.1.0
4
+ Summary: mymarkup package
5
+ Requires-Python: >=3.9
@@ -0,0 +1,11 @@
1
+ mymarkup/__init__.py,sha256=_g-fFhAw1XoPewOTDKhIxlFxhGgW2b_orB2P6JRp1fM,48
2
+ mymarkup/__main__.py,sha256=O952S2XTjGAzCANRtmhl2x9iGtRs2bISq6YwMSDjTBY,4787
3
+ mymarkup/mymarkup.py,sha256=pjjq_U9vETagCqCMpwyj4fT2m2pK0mdv_JtGOk0uqLg,14071
4
+ mymarkup/styles.css,sha256=DHtRBAaiRYwtFVVO_a-i7cNhTMjM6QyjPKMO7h6yRUM,5634
5
+ mymarkup/template.html,sha256=ygnED2MVwLgQLQgOzXkZ63ZO2mu2Qs_LSTLhdSGez1Q,550
6
+ mymarkup/tests.py,sha256=BCmDCClwjNzC84xK2Z_dhC17_L_cA0UrycldRSH7Wmo,8982
7
+ mymarkup-0.1.0.dist-info/METADATA,sha256=MKuUexYvMRWpG48b1D__xEGmX6lOg1IcsOMqUrHgN88,101
8
+ mymarkup-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ mymarkup-0.1.0.dist-info/entry_points.txt,sha256=FFOSNpry_GFyOrRKKdHnaxTfZpnU0SpuZtPbb8yKj2I,52
10
+ mymarkup-0.1.0.dist-info/top_level.txt,sha256=9JugM4Ir9wBijq0TqXJ5Pl5FtnBlalESxxB1wkRDNUU,9
11
+ mymarkup-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mymarkup = mymarkup.__main__:main
@@ -0,0 +1 @@
1
+ mymarkup