styledown 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.
- styledown/__init__.py +4 -0
- styledown/__main__.py +359 -0
- styledown/server.py +101 -0
- styledown/styledown.py +231 -0
- styledown/styles.css +340 -0
- styledown-0.1.0.dist-info/METADATA +10 -0
- styledown-0.1.0.dist-info/RECORD +10 -0
- styledown-0.1.0.dist-info/WHEEL +5 -0
- styledown-0.1.0.dist-info/entry_points.txt +2 -0
- styledown-0.1.0.dist-info/top_level.txt +1 -0
styledown/__init__.py
ADDED
styledown/__main__.py
ADDED
|
@@ -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())
|
styledown/server.py
ADDED
|
@@ -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)
|
styledown/styledown.py
ADDED
|
@@ -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))
|
styledown/styles.css
ADDED
|
@@ -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
|
+
styledown/__init__.py,sha256=sNHmmGhlyhoa-RxSPVapTaXDpRq-MeIuAO74bP7ba-0,115
|
|
2
|
+
styledown/__main__.py,sha256=mQwjoAxeT8WsAfP4onivxo_E2E2anc7bL7aRL9Dw-gc,11790
|
|
3
|
+
styledown/server.py,sha256=GaO_OwgDN-yiPNkbpfWKLNN4a8-8sWDQpVzcn2zo_MU,3075
|
|
4
|
+
styledown/styledown.py,sha256=_7tXX-Alg76ogJ5KpO4LV-JPn3S0bkEaP1mS8Xv93UU,6841
|
|
5
|
+
styledown/styles.css,sha256=xvwzFd_gl3wrAuosW-mua0tZ7hUkEhMxg9zqtRGaaT8,5591
|
|
6
|
+
styledown-0.1.0.dist-info/METADATA,sha256=vAZbJMfRHCOg3YObxnMvi0MSfXjfxBkTjRuxLuyk6M4,227
|
|
7
|
+
styledown-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
styledown-0.1.0.dist-info/entry_points.txt,sha256=tKx8Up9WJHJm_oE6rVF9F9Kl5NYupI0tC0gXulzqZXg,45
|
|
9
|
+
styledown-0.1.0.dist-info/top_level.txt,sha256=FSqugY1GIO1wDXzpT9icAxrYD0t-h-54saJG6Ti7pwI,10
|
|
10
|
+
styledown-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
styledown
|