styledown 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.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: styledown
3
+ Version: 0.1.0
4
+ Summary: A tiny Markdown renderer
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: mistletoe
7
+ Requires-Dist: pygments
8
+ Requires-Dist: fastapi
9
+ Requires-Dist: uvicorn
10
+ Requires-Dist: pyyaml
@@ -0,0 +1,3 @@
1
+ # Styledown
2
+
3
+ Please find the documentation at [hikaru.org](https://hikaru.org/projects/styledown/).
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "styledown"
7
+ version = "0.1.0"
8
+ description = "A tiny Markdown renderer"
9
+ requires-python = ">=3.9"
10
+ dependencies = ["mistletoe", "pygments", "fastapi", "uvicorn", "pyyaml"]
11
+
12
+ [project.scripts]
13
+ styledown = "styledown:main"
14
+
15
+ [tool.setuptools]
16
+ package-dir = {"" = "src"}
17
+
18
+ [tool.setuptools.package-data]
19
+ styledown = ["styles.css"]
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ __all__ = ["main", "metadata", "styledown"]
2
+
3
+ from .__main__ import main
4
+ from .styledown import metadata, styledown
@@ -0,0 +1,359 @@
1
+ import argparse
2
+ import os
3
+ import shutil
4
+ from pathlib import Path
5
+ from typing import Union
6
+ from urllib.parse import quote
7
+
8
+ from .server import run_server
9
+ from .styledown import metadata, split_frontmatter, styledown
10
+
11
+ TEMPLATE = """<!doctype html>
12
+ <html lang="en">
13
+ <head>
14
+ <meta charset="utf-8">
15
+ <meta name="viewport" content="width=device-width, initial-scale=1">
16
+ <title>{title}</title>
17
+ <style>{styles}</style>
18
+ </head>
19
+ <body>
20
+ {body}
21
+ </body>
22
+ </html>
23
+ """
24
+
25
+ BLACKLISTED_DIRECTORY_NAMES = {
26
+ ".git",
27
+ ".hg",
28
+ ".svn",
29
+ ".idea",
30
+ ".vscode",
31
+ "__pycache__",
32
+ "node_modules",
33
+ ".mypy_cache",
34
+ ".pytest_cache",
35
+ ".ruff_cache",
36
+ ".tox",
37
+ ".venv",
38
+ "venv",
39
+ "env",
40
+ "dist",
41
+ "build",
42
+ }
43
+
44
+ def load_styles() -> str:
45
+ """Load the bundled stylesheet contents."""
46
+
47
+ return (Path(__file__).resolve().parent / "styles.css").read_text(encoding="utf-8")
48
+
49
+ def escape_markdown_link_text(text: str, for_table: bool = False) -> str:
50
+ """Escape text used inside markdown link labels."""
51
+
52
+ escaped = (
53
+ text.replace("\\", "\\\\")
54
+ .replace("]", "\\]")
55
+ .replace("_", "\\_")
56
+ .replace("*", "\\*")
57
+ )
58
+ if for_table:
59
+ escaped = escaped.replace("|", "\\|")
60
+ return escaped
61
+
62
+ def escape_href(href: str) -> str:
63
+ """URL-escape each path component of a relative href."""
64
+
65
+ parts = href.split("/")
66
+ escaped_parts = []
67
+ for part in parts:
68
+ if part in ("", ".", ".."):
69
+ escaped_parts.append(part)
70
+ else:
71
+ escaped_parts.append(quote(part))
72
+ return "/".join(escaped_parts)
73
+
74
+ def ensure_dir_href(href: str) -> str:
75
+ if href in ("", "."):
76
+ return "./"
77
+ return href if href.endswith("/") else f"{href}/"
78
+
79
+ def relative_href(from_dir: Path, to_path: Path) -> str:
80
+ """Compute a browser-friendly relative href from one directory to a file."""
81
+
82
+ rel = os.path.relpath(to_path, start=from_dir)
83
+ return escape_href(rel.replace(os.sep, "/"))
84
+
85
+ def breadcrumb_markdown(root_dir: Path, page_path: Path) -> str:
86
+ """Build a breadcrumb line in styledown markdown."""
87
+
88
+ root_dir = root_dir.resolve()
89
+ page_path = page_path.resolve()
90
+ page_dir = page_path if page_path.is_dir() else page_path.parent
91
+ rel_path = page_path.relative_to(root_dir)
92
+
93
+ crumbs = []
94
+ if page_path == root_dir:
95
+ crumbs.append("Home")
96
+ else:
97
+ home_href = ensure_dir_href(relative_href(page_dir, root_dir))
98
+ crumbs.append(f"[Home]({home_href})")
99
+
100
+ current = root_dir
101
+ parts = [part for part in rel_path.parts if part and part != "."]
102
+ for i, part in enumerate(parts):
103
+ current = current / part
104
+ is_last = i == len(parts) - 1
105
+
106
+ is_md = page_path.is_file() and page_path.suffix == ".md"
107
+ label_part = Path(part).stem if is_last and is_md else part
108
+ label = escape_markdown_link_text(metadata(current)["title"])
109
+
110
+ if is_last:
111
+ crumbs.append(label)
112
+ else:
113
+ href = ensure_dir_href(relative_href(page_dir, current))
114
+ crumbs.append(f"[{label}]({href})")
115
+
116
+ return f".caption.muted: {' / '.join(crumbs)}\n\n"
117
+
118
+ def directory_listing_markdown(dir_path: Path) -> str:
119
+ """Build a markdown table listing directories and files."""
120
+
121
+ class MetaEntry:
122
+ def __init__(self, name: str, url: str, description: str):
123
+ self.name = name
124
+ self.url = url
125
+ self.description = description
126
+
127
+ entries: list[Union[Path, MetaEntry]] = []
128
+ for entry in dir_path.iterdir():
129
+ name = entry.name
130
+ if entry.is_dir() and name.lower() in BLACKLISTED_DIRECTORY_NAMES:
131
+ continue
132
+ if entry.is_file() and name.lower() == "index.md":
133
+ continue
134
+ if name.startswith("."):
135
+ continue
136
+ entries.append(entry)
137
+
138
+ dir_meta = metadata(dir_path)
139
+ links = dir_meta.get("links", [])
140
+ for item in links:
141
+ label = item.get("label", "").strip()
142
+ url = item.get("url", "").strip()
143
+ description = item.get("description", "").strip()
144
+ if not label or not url:
145
+ continue
146
+ entries.append(MetaEntry(label, url, description))
147
+
148
+ def get_name(entry):
149
+ if isinstance(entry, MetaEntry):
150
+ return entry.name
151
+ return metadata(entry)["title"]
152
+
153
+ entries.sort(key=get_name)
154
+
155
+ if not entries:
156
+ return "No files in this directory.\n"
157
+
158
+ lines = ["| Name | Description |", "| ---- | ----------- |"]
159
+ for entry in entries:
160
+ label_text = get_name(entry)
161
+ if isinstance(entry, MetaEntry):
162
+ href = entry.url
163
+ description = entry.description
164
+ elif entry.is_dir():
165
+ href = f"{entry.name}/"
166
+ description = metadata(entry).get("description", "")
167
+ elif entry.suffix.lower() == ".md":
168
+ href = entry.stem
169
+ description = metadata(entry).get("description", "")
170
+ else:
171
+ href = entry.name
172
+ description = ""
173
+
174
+ label = escape_markdown_link_text(label_text, for_table=True)
175
+ desc_cell = escape_markdown_link_text(description, for_table=True)
176
+ href_cell = href if isinstance(entry, MetaEntry) else escape_href(href)
177
+ lines.append(f"| <span class='nowrap'>[{label}]({href_cell})</span> | {desc_cell} |")
178
+
179
+ return "\n".join(lines) + "\n"
180
+
181
+ def write_html(output_path: Path, title: str, styles: str, body: str) -> None:
182
+ """Write a complete HTML document to output_path."""
183
+
184
+ output_path.write_text(
185
+ TEMPLATE.format(title=title, styles=styles, body=body),
186
+ encoding="utf-8",
187
+ )
188
+
189
+ def remove_dist_dir(dist_root: Path) -> None:
190
+ shutil.rmtree(dist_root, ignore_errors=True)
191
+
192
+ def convert_markdown_file(md_path: Path, root_dir: Path, output_dir: Path, styles: str) -> None:
193
+ """Convert a single .md file to a .html file in the corresponding location under the output_path directory."""
194
+
195
+ md_path = md_path.resolve()
196
+ root_dir = root_dir.resolve()
197
+ output_dir = output_dir.resolve()
198
+
199
+ if md_path.suffix.lower() != ".md":
200
+ raise ValueError(f"Not a markdown file: {md_path}")
201
+
202
+ breadcrumb_path = md_path.parent if md_path.name.lower() == "index.md" else md_path
203
+ breadcrumb = breadcrumb_markdown(root_dir, breadcrumb_path)
204
+ _, markdown_body = split_frontmatter(md_path.read_text(encoding="utf-8"))
205
+ markdown_text = breadcrumb + markdown_body
206
+
207
+ body = styledown(markdown_text)
208
+ title = metadata(md_path)["title"]
209
+
210
+ output_path = (output_dir / md_path.relative_to(root_dir)).with_suffix(".html")
211
+ output_path.parent.mkdir(parents=True, exist_ok=True)
212
+ write_html(output_path, title, styles, body)
213
+
214
+ def ensure_directory_index(dir_path: Path, root_dir: Path, output_dir: Path, styles: str) -> None:
215
+ """Write index.html as a directory listing if index.md is not present."""
216
+
217
+ dir_path = dir_path.resolve()
218
+ root_dir = root_dir.resolve()
219
+ output_dir = output_dir.resolve()
220
+
221
+ index_md = dir_path / "index.md"
222
+ if index_md.exists():
223
+ return
224
+
225
+ title = "Home" if dir_path == root_dir else metadata(dir_path)["title"]
226
+ breadcrumb = breadcrumb_markdown(root_dir, dir_path)
227
+ listing = directory_listing_markdown(dir_path)
228
+ body = styledown(breadcrumb + listing)
229
+
230
+ output_path = output_dir / dir_path.relative_to(root_dir) / "index.html"
231
+ output_path.parent.mkdir(parents=True, exist_ok=True)
232
+ write_html(output_path, title, styles, body)
233
+
234
+ def convert_tree(root_dir: Path, output_dir: Path, styles: str) -> int:
235
+ """Convert all markdown files under root_dir, skipping blacklisted directories."""
236
+
237
+ root_dir = root_dir.resolve()
238
+ output_dir = output_dir.resolve()
239
+ count = 0
240
+ blacklist = {name.lower() for name in BLACKLISTED_DIRECTORY_NAMES}
241
+
242
+ for current_dir_str, dirnames, filenames in os.walk(root_dir):
243
+ dirnames[:] = [d for d in dirnames if d.lower() not in blacklist and not d.startswith(".")]
244
+ current_dir = Path(current_dir_str)
245
+
246
+ for d in list(dirnames):
247
+ src_dir = current_dir / d
248
+ if not src_dir.is_symlink():
249
+ continue
250
+ dst_dir = output_dir / current_dir.relative_to(root_dir) / d
251
+ dst_dir.parent.mkdir(parents=True, exist_ok=True)
252
+ dst_dir.symlink_to(os.readlink(src_dir), target_is_directory=True)
253
+ print(f"[+] Copied {dst_dir.relative_to(output_dir).as_posix()}")
254
+ count += 1
255
+ dirnames.remove(d)
256
+
257
+ files = [current_dir / f for f in filenames]
258
+
259
+ for path in files:
260
+ if path.name.startswith("."):
261
+ continue
262
+ if path.suffix == ".md":
263
+ convert_markdown_file(path, root_dir, output_dir, styles)
264
+ rel = path.relative_to(root_dir).as_posix()
265
+ print(f"[+] Converted {rel}")
266
+ count += 1
267
+ else:
268
+ rel = path.relative_to(root_dir)
269
+ dst_path = output_dir / rel
270
+ dst_path.parent.mkdir(parents=True, exist_ok=True)
271
+ if path.is_symlink():
272
+ dst_path.symlink_to(os.readlink(path), target_is_directory=False)
273
+ print(f"[+] Copied {rel.as_posix()}")
274
+ count += 1
275
+ else:
276
+ shutil.copy2(path, dst_path)
277
+ print(f"[+] Copied {rel.as_posix()}")
278
+ count += 1
279
+
280
+ ensure_directory_index(current_dir, root_dir, output_dir, styles)
281
+
282
+ return count
283
+
284
+ def convert_domains_tree(root_dir: Path, output_dir: Path, styles: str) -> int:
285
+ count = 0
286
+ blacklist = {name.lower() for name in BLACKLISTED_DIRECTORY_NAMES}
287
+
288
+ for entry in root_dir.iterdir():
289
+ if entry.name.startswith("."):
290
+ continue
291
+ if entry.name.lower() in blacklist:
292
+ continue
293
+ if not entry.is_dir():
294
+ continue
295
+
296
+ out_site_dir = output_dir / entry.name
297
+ if entry.is_symlink():
298
+ out_site_dir.symlink_to(os.readlink(entry), target_is_directory=True)
299
+ continue
300
+
301
+ count += convert_tree(entry, out_site_dir, styles)
302
+
303
+ return count
304
+
305
+ def main(argv=None) -> int:
306
+ parser = argparse.ArgumentParser(prog="styledown")
307
+ parser.add_argument(
308
+ "--src",
309
+ default="./src/",
310
+ help="Markdown file or directory to convert (default: ./src/).",
311
+ )
312
+ parser.add_argument(
313
+ "--out",
314
+ default="./dist/",
315
+ help="Directory to place the converted files (default: ./dist/).",
316
+ )
317
+ parser.add_argument(
318
+ "--host",
319
+ default="localhost",
320
+ help="Host interface to bind the server to (default: localhost).",
321
+ )
322
+ parser.add_argument(
323
+ "--port",
324
+ type=int,
325
+ default=1234,
326
+ help="Port to bind the server to (default: 1234).",
327
+ )
328
+ parser.add_argument(
329
+ "--domains",
330
+ action="store_true",
331
+ help="Serve multiple sites from subdirectories by mapping the request Host header to a subdirectory.",
332
+ )
333
+ args = parser.parse_args(argv)
334
+
335
+ target = Path(args.src)
336
+ if not target.exists():
337
+ raise FileNotFoundError(target)
338
+
339
+ dist_root = Path(args.out).resolve()
340
+ print(f"[+] Removing {args.out}")
341
+ remove_dist_dir(dist_root)
342
+ dist_root.mkdir(parents=True, exist_ok=True)
343
+
344
+ styles = load_styles()
345
+ root_dir: Path
346
+
347
+ if not target.is_dir():
348
+ raise ValueError(f"Path must be a directory: {target}")
349
+
350
+ if args.domains:
351
+ count = convert_domains_tree(target, dist_root, styles)
352
+ else:
353
+ count = convert_tree(target, dist_root, styles)
354
+ print(f"[+] Converted {count} files")
355
+ run_server(dist_root, host=args.host, port=args.port, domains=args.domains)
356
+ return 0
357
+
358
+ if __name__ == "__main__":
359
+ raise SystemExit(main())
@@ -0,0 +1,101 @@
1
+ import re
2
+ import posixpath
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from fastapi import FastAPI, HTTPException, Request
7
+ from fastapi.responses import FileResponse
8
+
9
+ def _path_within_root(root: Path, path: Path) -> bool:
10
+ try:
11
+ path.relative_to(root)
12
+ except ValueError:
13
+ return False
14
+ return True
15
+
16
+ def _sanitize_url_path(url_path: str) -> Optional[Path]:
17
+ normalized = url_path.lstrip("/")
18
+ normalized = posixpath.normpath(normalized)
19
+
20
+ if normalized in ("", "."):
21
+ return Path("")
22
+
23
+ parts = [p for p in normalized.split("/") if p]
24
+ if any(p == ".." for p in parts):
25
+ return None
26
+
27
+ return Path(*parts)
28
+
29
+ def _sanitize_host(host: str) -> Optional[str]:
30
+ host = host.strip().lower()
31
+ if not host:
32
+ return None
33
+
34
+ match = re.fullmatch(
35
+ r"(?P<name>"
36
+ r"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*"
37
+ r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
38
+ r")"
39
+ r"\.?"
40
+ r"(?::(?P<port>[0-9]+))?",
41
+ host,
42
+ )
43
+ if match is None:
44
+ return None
45
+
46
+ name = match.group("name")
47
+
48
+ if len(name) > 253:
49
+ return None
50
+
51
+ return name
52
+
53
+ def create_app(root: Path, domains: bool = False) -> FastAPI:
54
+ root = root.resolve()
55
+ app = FastAPI()
56
+
57
+ @app.get("/{url_path:path}")
58
+ async def get_page(url_path: str, request: Request):
59
+ request_root = root
60
+ if domains:
61
+ host = _sanitize_host(request.headers.get("host", ""))
62
+ if host is None:
63
+ raise HTTPException(status_code=404)
64
+ request_root = (root / host).resolve(strict=False)
65
+ if not _path_within_root(root, request_root) or not request_root.is_dir():
66
+ raise HTTPException(status_code=404)
67
+
68
+ requested_rel = _sanitize_url_path(url_path)
69
+ if requested_rel is None:
70
+ raise HTTPException(status_code=404)
71
+
72
+ requested = (request_root / requested_rel).resolve(strict=False)
73
+ if not _path_within_root(request_root, requested):
74
+ raise HTTPException(status_code=404)
75
+
76
+ if requested.is_file():
77
+ return FileResponse(requested)
78
+
79
+ if (
80
+ requested_rel.as_posix() not in ("", ".")
81
+ and requested.name
82
+ and not requested.name.lower().endswith(".html")
83
+ ):
84
+ html_candidate = (requested.parent / f"{requested.name}.html").resolve(strict=False)
85
+ if _path_within_root(request_root, html_candidate) and html_candidate.is_file():
86
+ return FileResponse(html_candidate)
87
+
88
+ if requested.is_dir():
89
+ index_candidate = (requested / "index.html").resolve(strict=False)
90
+ if _path_within_root(request_root, index_candidate) and index_candidate.is_file():
91
+ return FileResponse(index_candidate)
92
+
93
+ raise HTTPException(status_code=404)
94
+
95
+ return app
96
+
97
+ def run_server(root: Path, host: str, port: int, domains: bool = False) -> None:
98
+ import uvicorn
99
+
100
+ app = create_app(root, domains=domains)
101
+ uvicorn.run(app, host=host, port=port)
@@ -0,0 +1,231 @@
1
+ import re
2
+ from pathlib import Path
3
+ from typing import Any, Optional
4
+
5
+ import mistletoe
6
+ import yaml
7
+ from pygments import highlight
8
+ from pygments.formatters.html import HtmlFormatter
9
+ from pygments.lexers import get_lexer_by_name, guess_lexer, guess_lexer_for_filename
10
+ from pygments.lexers.special import TextLexer
11
+ from pygments.util import ClassNotFound
12
+
13
+ # Matches:
14
+ # .card:
15
+ # .card.big:
16
+ # .card.big: inline content
17
+ DIV_RE = re.compile(
18
+ r"^(\s*)((?:\.[A-Za-z_][\w-]*)+):(.*)$"
19
+ )
20
+
21
+ def indent_width(line: str) -> int:
22
+ return len(line) - len(line.lstrip(" "))
23
+
24
+ def parse_classes(class_expr: str) -> str:
25
+ return class_expr.replace(".", " ").strip()
26
+
27
+ def first_child_indent(
28
+ lines: list[str],
29
+ start: int,
30
+ base_indent: int,
31
+ ) -> int:
32
+ for i in range(start, len(lines)):
33
+ line = lines[i]
34
+
35
+ if not line.strip():
36
+ continue
37
+
38
+ indent = indent_width(line)
39
+
40
+ if indent <= base_indent:
41
+ raise ValueError(
42
+ f"Line {i + 1}: div block has no indented content"
43
+ )
44
+
45
+ return indent
46
+
47
+ raise ValueError(
48
+ f"Line {start}: div block has no content"
49
+ )
50
+
51
+ def preprocess_div_blocks(text: str) -> str:
52
+ lines = text.splitlines()
53
+ output = []
54
+
55
+ stack = [] # [{"base_indent": int, "content_indent": int}]
56
+ in_fenced_code_block = False
57
+
58
+ for i, line in enumerate(lines):
59
+ line_number = i + 1
60
+
61
+ raw_indent = indent_width(line)
62
+ stripped = line.lstrip()
63
+
64
+ while (
65
+ stack
66
+ and stripped
67
+ and raw_indent <= stack[-1]["base_indent"]
68
+ and not in_fenced_code_block
69
+ ):
70
+ output.append("")
71
+ output.append("</div>")
72
+ output.append("")
73
+ stack.pop()
74
+
75
+ content_indent = stack[-1]["content_indent"] if stack else 0
76
+ if raw_indent == content_indent and stripped.startswith("```"):
77
+ in_fenced_code_block = not in_fenced_code_block
78
+
79
+ # Ensure code blocks render correctly.
80
+ if stripped:
81
+ if stack:
82
+ current = stack[-1]
83
+ if raw_indent >= current["content_indent"] + 4:
84
+ output.append(line[current["content_indent"]:])
85
+ continue
86
+ else:
87
+ if raw_indent >= 4:
88
+ output.append(line)
89
+ continue
90
+
91
+ match = None if in_fenced_code_block else DIV_RE.match(line)
92
+
93
+ if match:
94
+ indent_str, class_expr, inline_content = match.groups()
95
+
96
+ base_indent = len(indent_str)
97
+ classes = parse_classes(class_expr)
98
+ inline_content = inline_content.lstrip()
99
+
100
+ # Inline form:
101
+ # .card.big: hello
102
+ if inline_content:
103
+ output.append("")
104
+ output.append(f'<div class="{classes}">')
105
+ output.append("")
106
+ output.append(inline_content)
107
+ output.append("")
108
+ output.append("</div>")
109
+ output.append("")
110
+
111
+ # Block form:
112
+ # .card.big:
113
+ # hello
114
+ else:
115
+ content_indent = first_child_indent(
116
+ lines,
117
+ i + 1,
118
+ base_indent,
119
+ )
120
+
121
+ output.append("")
122
+ output.append(f'<div class="{classes}">')
123
+ output.append("")
124
+
125
+ stack.append({
126
+ "base_indent": base_indent,
127
+ "content_indent": content_indent,
128
+ })
129
+
130
+ continue
131
+
132
+ if stack and stripped:
133
+ current = stack[-1]
134
+
135
+ if raw_indent < current["content_indent"]:
136
+ raise ValueError(
137
+ f"Line {line_number}: invalid indentation "
138
+ f"(got {raw_indent}, expected >= "
139
+ f"{current['content_indent']})"
140
+ )
141
+
142
+ line = line[current["content_indent"]:]
143
+
144
+ output.append(line)
145
+
146
+ while stack:
147
+ output.append("")
148
+ output.append("</div>")
149
+ output.append("")
150
+ stack.pop()
151
+
152
+ return "\n".join(output)
153
+
154
+ class PygmentsHtmlRenderer(mistletoe.HtmlRenderer):
155
+ formatter = HtmlFormatter(noclasses=True)
156
+
157
+ def __init__(self, filename=None, *extras, **kwargs):
158
+ super().__init__(*extras, **kwargs)
159
+ self._filename = filename
160
+
161
+ def render_block_code(self, token):
162
+ code = token.content
163
+
164
+ lexer = None
165
+ if token.language:
166
+ try:
167
+ lexer = get_lexer_by_name(token.language)
168
+ except ClassNotFound:
169
+ lexer = None
170
+
171
+ if lexer is None and self._filename:
172
+ try:
173
+ lexer = guess_lexer_for_filename(self._filename, code)
174
+ except ClassNotFound:
175
+ lexer = None
176
+
177
+ if lexer is None:
178
+ try:
179
+ lexer = guess_lexer(code)
180
+ except ClassNotFound:
181
+ lexer = TextLexer()
182
+
183
+ return highlight(code, lexer, self.formatter)
184
+
185
+ def split_frontmatter(text: str):
186
+ if not text.startswith("---\n"):
187
+ return {}, text
188
+ _, rest = text.split("---\n", 1)
189
+ frontmatter, markdown = rest.split("\n---\n", 1)
190
+ return yaml.safe_load(frontmatter), markdown
191
+
192
+ def title_from_slug(slug: str) -> str:
193
+ """Compute the HTML page title from a slug."""
194
+
195
+ return slug.replace("-", " ").replace("_", " ").title()
196
+
197
+ def metadata(path: Path) -> dict[str, Any]:
198
+ def merge_title(markdown: str, meta: Any) -> dict:
199
+ meta = meta if isinstance(meta, dict) else {}
200
+ if meta.get("title"):
201
+ return meta
202
+ for line in markdown.splitlines():
203
+ match = re.match(r"^[^#]*#([^#]*)$", line)
204
+ if match:
205
+ meta["title"] = match.group(1).strip()
206
+ return meta
207
+ meta["title"] = title_from_slug(path.stem)
208
+ return meta
209
+
210
+ if path.is_dir():
211
+ index_md = path / "index.md"
212
+ if index_md.exists():
213
+ loaded, markdown = split_frontmatter(index_md.read_text(encoding="utf-8"))
214
+ return merge_title(markdown, loaded)
215
+
216
+ meta_path = path / ".meta.yaml"
217
+ if not meta_path.exists():
218
+ return {}
219
+ loaded = yaml.safe_load(meta_path.read_text(encoding="utf-8")) or {}
220
+ return merge_title("", loaded)
221
+
222
+ if path.is_file() and path.suffix.lower() == ".md":
223
+ loaded, markdown = split_frontmatter(path.read_text(encoding="utf-8"))
224
+ return merge_title(markdown, loaded)
225
+
226
+ return {"title": path.name}
227
+
228
+ def styledown(text: str, filename: Optional[str] = None) -> str:
229
+ _, text = split_frontmatter(text)
230
+ text = preprocess_div_blocks(text)
231
+ return mistletoe.markdown(text, renderer=lambda: PygmentsHtmlRenderer(filename=filename))
@@ -0,0 +1,340 @@
1
+ /* =========================================================
2
+ Base document
3
+ ========================================================= */
4
+
5
+ :root {
6
+ --bg: #ffffff;
7
+ --fg: #222222;
8
+
9
+ --border: #e5e7eb;
10
+ --muted: #64748b;
11
+
12
+ --blue: #4f7cff;
13
+ --green: #16a34a;
14
+ --orange: #d97706;
15
+ --red: #dc2626;
16
+
17
+ --surface: #fafafa;
18
+ --surface-2: #f8fafc;
19
+ --surface-3: #f1f5f9;
20
+ }
21
+
22
+ * {
23
+ box-sizing: border-box;
24
+ }
25
+
26
+ body {
27
+ font-family: system-ui, sans-serif;
28
+ line-height: 1.65;
29
+ max-width: 90ch;
30
+ margin: 3rem auto;
31
+ padding: 0 1rem;
32
+
33
+ color: var(--fg);
34
+ background: var(--bg);
35
+ }
36
+
37
+ h1, h2, h3 {
38
+ line-height: 1.2;
39
+ }
40
+
41
+ p,
42
+ ul,
43
+ ol,
44
+ table,
45
+ blockquote {
46
+ margin: 1rem 0;
47
+ }
48
+
49
+ pre {
50
+ overflow-x: auto;
51
+ padding: 1rem;
52
+ border-radius: 0.75rem;
53
+ background: #111827;
54
+ color: #f9fafb;
55
+ }
56
+
57
+ code {
58
+ font-family: ui-monospace, monospace;
59
+ }
60
+
61
+ /* =========================================================
62
+ Links
63
+ ========================================================= */
64
+
65
+ a,
66
+ a:visited,
67
+ a:active {
68
+ color: var(--blue);
69
+ text-decoration: none;
70
+ }
71
+
72
+ a:hover {
73
+ color: var(--blue);
74
+ text-decoration: underline;
75
+ }
76
+
77
+ /* =========================================================
78
+ Generic blocks
79
+ ========================================================= */
80
+
81
+ .card,
82
+ .note,
83
+ .warning,
84
+ .success,
85
+ .error,
86
+ .quote,
87
+ .hero,
88
+ .stat {
89
+ margin: 1.25rem 0;
90
+ border-radius: 0.85rem;
91
+ }
92
+
93
+ .card,
94
+ .note,
95
+ .warning,
96
+ .success,
97
+ .error,
98
+ .hero,
99
+ .stat {
100
+ padding: 1rem 1.25rem;
101
+ border: 1px solid var(--border);
102
+ }
103
+
104
+ /* =========================================================
105
+ Semantic callouts
106
+ ========================================================= */
107
+
108
+ .note {
109
+ background: #f5f8ff;
110
+ border-left: 0.35rem solid var(--blue);
111
+ }
112
+
113
+ .success {
114
+ background: #effaf2;
115
+ border-left: 0.35rem solid var(--green);
116
+ }
117
+
118
+ .warning {
119
+ background: #fff8eb;
120
+ border-left: 0.35rem solid var(--orange);
121
+ }
122
+
123
+ .error {
124
+ background: #fff1f2;
125
+ border-left: 0.35rem solid var(--red);
126
+ }
127
+
128
+ /* =========================================================
129
+ Layouts
130
+ ========================================================= */
131
+
132
+ .columns {
133
+ display: grid;
134
+ grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
135
+ gap: 1rem;
136
+
137
+ margin: 1.5rem 0;
138
+ }
139
+
140
+ .grid {
141
+ display: grid;
142
+ grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
143
+ gap: 1rem;
144
+
145
+ margin: 1.5rem 0;
146
+ }
147
+
148
+ /* =========================================================
149
+ Hero
150
+ ========================================================= */
151
+
152
+ .hero {
153
+ padding: 2rem;
154
+ background:
155
+ linear-gradient(
156
+ 135deg,
157
+ #f5f7ff,
158
+ #ffffff
159
+ );
160
+
161
+ border: 1px solid var(--border);
162
+ }
163
+
164
+ .hero > :first-child {
165
+ margin-top: 0;
166
+ }
167
+
168
+ .hero > :last-child {
169
+ margin-bottom: 0;
170
+ }
171
+
172
+ /* =========================================================
173
+ Stats
174
+ ========================================================= */
175
+
176
+ .stat {
177
+ background: var(--surface-2);
178
+ }
179
+
180
+ .stat strong {
181
+ display: block;
182
+ font-size: 2rem;
183
+ line-height: 1.1;
184
+ }
185
+
186
+ /* =========================================================
187
+ Quote
188
+ ========================================================= */
189
+
190
+ .quote {
191
+ padding: 0.5rem 1.25rem;
192
+
193
+ border-left: 0.35rem solid #94a3b8;
194
+
195
+ background: var(--surface-3);
196
+ color: #475569;
197
+ font-size: 1.1rem;
198
+ font-style: italic;
199
+ }
200
+
201
+ /* =========================================================
202
+ Tables
203
+ ========================================================= */
204
+
205
+ .table-wrap {
206
+ overflow-x: auto;
207
+ margin: 1.5rem 0;
208
+ }
209
+
210
+ table {
211
+ width: 100%;
212
+ border-collapse: collapse;
213
+ }
214
+
215
+ th,
216
+ td {
217
+ padding: 0.65rem 0.75rem;
218
+ text-align: left;
219
+
220
+ border-bottom: 1px solid var(--border);
221
+ }
222
+
223
+ th {
224
+ background: var(--surface-2);
225
+ font-weight: 650;
226
+ }
227
+
228
+ /* =========================================================
229
+ Typography helpers
230
+ ========================================================= */
231
+
232
+ .caption {
233
+ margin-top: -0.5rem;
234
+
235
+ color: var(--muted);
236
+ font-size: 0.92rem;
237
+ }
238
+
239
+ .center {
240
+ text-align: center;
241
+ }
242
+
243
+ .small {
244
+ font-size: 0.92rem;
245
+ }
246
+
247
+ .large {
248
+ font-size: 1.15rem;
249
+ }
250
+
251
+ .muted {
252
+ color: var(--muted);
253
+ }
254
+
255
+ .nowrap {
256
+ white-space: nowrap;
257
+ }
258
+
259
+ /* =========================================================
260
+ Colors
261
+ ========================================================= */
262
+
263
+ .red {
264
+ --theme-color: var(--red);
265
+ }
266
+
267
+ .green {
268
+ --theme-color: var(--green);
269
+ }
270
+
271
+ .blue {
272
+ --theme-color: var(--blue);
273
+ }
274
+
275
+ .orange {
276
+ --theme-color: var(--orange);
277
+ }
278
+
279
+ /* =========================================================
280
+ Buttons
281
+ ========================================================= */
282
+
283
+ .buttons {
284
+ --button-color: var(--theme-color, var(--muted));
285
+ }
286
+
287
+ .buttons a, .buttons a:visited {
288
+ display: inline-block;
289
+ margin: 0.25rem 0.35rem 0.25rem 0;
290
+ padding: 0.55rem 0.85rem;
291
+ border-radius: 0.8rem;
292
+ border: 1px solid var(--button-color);
293
+ background: var(--surface-2);
294
+ color: var(--button-color);
295
+ font-weight: 600;
296
+ }
297
+
298
+ .buttons a:hover {
299
+ background: var(--surface);
300
+ text-decoration: none;
301
+ }
302
+
303
+ .buttons a:active {
304
+ transform: translateY(1px);
305
+ }
306
+
307
+ .buttons a:focus-visible {
308
+ outline: 2px solid var(--button-color);
309
+ outline-offset: 2px;
310
+ }
311
+
312
+ /* =========================================================
313
+ Visual helpers
314
+ ========================================================= */
315
+
316
+ .shadow {
317
+ box-shadow:
318
+ 0 6px 18px rgba(0,0,0,0.08);
319
+ }
320
+
321
+ .round {
322
+ border-radius: 1.5rem;
323
+ }
324
+
325
+ .borderless {
326
+ border: none;
327
+ }
328
+
329
+ .compact {
330
+ padding: 0.6rem 0.85rem;
331
+ }
332
+
333
+ /* =========================================================
334
+ Combinations become very powerful
335
+ ========================================================= */
336
+
337
+ /* Example:
338
+ .card.shadow.round
339
+ .note.compact.small
340
+ */
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: styledown
3
+ Version: 0.1.0
4
+ Summary: A tiny Markdown renderer
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: mistletoe
7
+ Requires-Dist: pygments
8
+ Requires-Dist: fastapi
9
+ Requires-Dist: uvicorn
10
+ Requires-Dist: pyyaml
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/styledown/__init__.py
4
+ src/styledown/__main__.py
5
+ src/styledown/server.py
6
+ src/styledown/styledown.py
7
+ src/styledown/styles.css
8
+ src/styledown.egg-info/PKG-INFO
9
+ src/styledown.egg-info/SOURCES.txt
10
+ src/styledown.egg-info/dependency_links.txt
11
+ src/styledown.egg-info/entry_points.txt
12
+ src/styledown.egg-info/requires.txt
13
+ src/styledown.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ styledown = styledown:main
@@ -0,0 +1,5 @@
1
+ mistletoe
2
+ pygments
3
+ fastapi
4
+ uvicorn
5
+ pyyaml
@@ -0,0 +1 @@
1
+ styledown