xuanxin 0.1.1.dev0__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.
- xuanxin/__init__.py +18 -0
- xuanxin/book.py +226 -0
- xuanxin/builder.py +128 -0
- xuanxin/cli.py +195 -0
- xuanxin/extensions.py +207 -0
- xuanxin/footnotes.py +41 -0
- xuanxin/image_attrs.py +513 -0
- xuanxin/includes.py +185 -0
- xuanxin/latex.py +35 -0
- xuanxin/note_sections.py +106 -0
- xuanxin/paginate.py +48 -0
- xuanxin/processor.py +201 -0
- xuanxin/renderer.py +175 -0
- xuanxin/section_nav.py +58 -0
- xuanxin/static/themes/_base.css +1081 -0
- xuanxin/static/themes/dark.css +23 -0
- xuanxin/static/themes/default.css +23 -0
- xuanxin/static/themes/minimal.css +23 -0
- xuanxin/templates/book_index.html +44 -0
- xuanxin/templates/index.html +53 -0
- xuanxin/templates/post.html +123 -0
- xuanxin-0.1.1.dev0.dist-info/METADATA +164 -0
- xuanxin-0.1.1.dev0.dist-info/RECORD +26 -0
- xuanxin-0.1.1.dev0.dist-info/WHEEL +5 -0
- xuanxin-0.1.1.dev0.dist-info/entry_points.txt +2 -0
- xuanxin-0.1.1.dev0.dist-info/top_level.txt +1 -0
xuanxin/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""xuanxin — Markdown blog to beautiful static HTML."""
|
|
2
|
+
|
|
3
|
+
from xuanxin.book import collect_book_markdown, default_book_output_dir, render_book
|
|
4
|
+
from xuanxin.builder import BlogBuilder, render_file
|
|
5
|
+
from xuanxin.processor import MarkdownProcessor, process_file, process_string
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.1.dev0"
|
|
8
|
+
__all__ = [
|
|
9
|
+
"BlogBuilder",
|
|
10
|
+
"MarkdownProcessor",
|
|
11
|
+
"collect_book_markdown",
|
|
12
|
+
"default_book_output_dir",
|
|
13
|
+
"process_file",
|
|
14
|
+
"process_string",
|
|
15
|
+
"render_book",
|
|
16
|
+
"render_file",
|
|
17
|
+
"__version__",
|
|
18
|
+
]
|
xuanxin/book.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Discover and render autobiography / bubble-style book chapters to HTML."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from xuanxin.includes import find_peanut_config
|
|
11
|
+
from xuanxin.paginate import count_pages
|
|
12
|
+
from xuanxin.processor import MarkdownProcessor
|
|
13
|
+
from xuanxin.renderer import BlogRenderer
|
|
14
|
+
|
|
15
|
+
# Bubble merge order: preface → chapters 1–12 → appendix (chapterx)
|
|
16
|
+
LANG_SUFFIX = {
|
|
17
|
+
"en": "",
|
|
18
|
+
"zh": "_zh",
|
|
19
|
+
"cn": "_zh",
|
|
20
|
+
"tc": "_tc",
|
|
21
|
+
"jp": "_jp",
|
|
22
|
+
"sp": "_sp",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def normalize_book_lang(lang: str) -> str:
|
|
27
|
+
"""Normalize CLI language codes to canonical suffix keys."""
|
|
28
|
+
key = lang.lower().strip().replace("-", "_")
|
|
29
|
+
if key in ("cn", "zh_cn"):
|
|
30
|
+
return "zh"
|
|
31
|
+
return key
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def default_book_output_dir(root: Path | str, lang: str) -> Path:
|
|
35
|
+
"""Return the default HTML output directory for a book language."""
|
|
36
|
+
root = Path(root).resolve()
|
|
37
|
+
lang = normalize_book_lang(lang)
|
|
38
|
+
if lang == "en":
|
|
39
|
+
return root / "book_html"
|
|
40
|
+
return root / f"book_html_{lang}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class BookChapter:
|
|
45
|
+
"""One rendered unit in reading order."""
|
|
46
|
+
|
|
47
|
+
md_path: Path
|
|
48
|
+
html_name: str
|
|
49
|
+
title: str
|
|
50
|
+
order: int
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_book_repo_root(path: Path) -> bool:
|
|
54
|
+
root = path.resolve()
|
|
55
|
+
if next(root.glob("chapter1-*"), None) is not None:
|
|
56
|
+
return True
|
|
57
|
+
chapterx = root / "chapterx"
|
|
58
|
+
return chapterx.is_dir() and any(chapterx.glob("*.md"))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def discover_book_repo_root(start: Path | None = None) -> Path:
|
|
62
|
+
seen: set[Path] = set()
|
|
63
|
+
cur = (start or Path.cwd()).resolve()
|
|
64
|
+
while cur not in seen:
|
|
65
|
+
seen.add(cur)
|
|
66
|
+
if is_book_repo_root(cur):
|
|
67
|
+
return cur
|
|
68
|
+
if cur.parent == cur:
|
|
69
|
+
break
|
|
70
|
+
cur = cur.parent
|
|
71
|
+
raise FileNotFoundError(
|
|
72
|
+
"Cannot find book repository root (expected chapter1-*/ or chapterx/). "
|
|
73
|
+
"Run from the book repo or pass --root."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def collect_book_markdown(root: Path, lang: str = "en") -> list[Path]:
|
|
78
|
+
"""Return markdown files in reading order (preface, ch.1–12, appendix)."""
|
|
79
|
+
root = root.resolve()
|
|
80
|
+
lang = normalize_book_lang(lang)
|
|
81
|
+
suffix = LANG_SUFFIX.get(lang, lang if lang.startswith("_") else "")
|
|
82
|
+
if lang not in LANG_SUFFIX and not suffix.startswith("_"):
|
|
83
|
+
suffix = f"_{lang}" if lang != "en" else ""
|
|
84
|
+
|
|
85
|
+
files: list[Path] = []
|
|
86
|
+
|
|
87
|
+
preface = root / "chapterx" / (f"preface{suffix}.md" if suffix else "preface.md")
|
|
88
|
+
if preface.is_file():
|
|
89
|
+
files.append(preface)
|
|
90
|
+
|
|
91
|
+
for n in range(1, 13):
|
|
92
|
+
for d in sorted(root.glob(f"chapter{n}-*")):
|
|
93
|
+
if not d.is_dir():
|
|
94
|
+
continue
|
|
95
|
+
name = f"chapter{n}{suffix}.md" if suffix else f"chapter{n}.md"
|
|
96
|
+
md = d / name
|
|
97
|
+
if md.is_file():
|
|
98
|
+
files.append(md)
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
appendix = root / "chapterx" / (f"chapterx{suffix}.md" if suffix else "chapterx.md")
|
|
102
|
+
if appendix.is_file():
|
|
103
|
+
files.append(appendix)
|
|
104
|
+
|
|
105
|
+
return files
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def chapter_html_name(md_path: Path) -> str:
|
|
109
|
+
"""Stable HTML filename for a book markdown file."""
|
|
110
|
+
stem = md_path.stem
|
|
111
|
+
parent = md_path.parent.name
|
|
112
|
+
if stem.startswith("preface"):
|
|
113
|
+
return f"{stem}.html"
|
|
114
|
+
m = re.match(r"chapter(\d+)", parent)
|
|
115
|
+
if m:
|
|
116
|
+
return f"chapter{m.group(1).zfill(2)}.html"
|
|
117
|
+
return f"{stem}.html"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def rewrite_chapter_img_paths(html: str, md_path: Path, book_root: Path) -> str:
|
|
121
|
+
"""Point img/ at the source chapter folder from flat book_html/."""
|
|
122
|
+
chapter_dir = md_path.parent.resolve()
|
|
123
|
+
try:
|
|
124
|
+
rel = chapter_dir.relative_to(book_root.resolve()).as_posix()
|
|
125
|
+
except ValueError:
|
|
126
|
+
rel = chapter_dir.name
|
|
127
|
+
prefix = f"../{rel}/img/"
|
|
128
|
+
return re.sub(r'src="img/', f'src="{prefix}', html)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def render_book(
|
|
132
|
+
root: Path | str,
|
|
133
|
+
*,
|
|
134
|
+
output_dir: Path | str | None = None,
|
|
135
|
+
lang: str = "en",
|
|
136
|
+
site_title: str = "",
|
|
137
|
+
theme: str = "default",
|
|
138
|
+
custom_css: Path | None = None,
|
|
139
|
+
mathjax: bool = True,
|
|
140
|
+
include_config: Path | str | None = None,
|
|
141
|
+
processor: MarkdownProcessor | None = None,
|
|
142
|
+
) -> dict[str, Any]:
|
|
143
|
+
"""Render full book into output_dir with index and prev/next navigation."""
|
|
144
|
+
book_root = Path(root).resolve()
|
|
145
|
+
out_dir = (
|
|
146
|
+
Path(output_dir).resolve()
|
|
147
|
+
if output_dir is not None
|
|
148
|
+
else default_book_output_dir(book_root, lang)
|
|
149
|
+
)
|
|
150
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
|
|
152
|
+
md_files = collect_book_markdown(book_root, lang)
|
|
153
|
+
if not md_files:
|
|
154
|
+
raise FileNotFoundError(f"No chapter markdown found under {book_root} (lang={lang})")
|
|
155
|
+
|
|
156
|
+
cfg = include_config or find_peanut_config(book_root)
|
|
157
|
+
proc = processor or MarkdownProcessor(include_config=cfg)
|
|
158
|
+
renderer = BlogRenderer(
|
|
159
|
+
site_title=site_title,
|
|
160
|
+
base_url="",
|
|
161
|
+
theme=theme,
|
|
162
|
+
custom_css=custom_css,
|
|
163
|
+
mathjax=mathjax,
|
|
164
|
+
)
|
|
165
|
+
renderer.copy_static_assets(out_dir, custom_css)
|
|
166
|
+
|
|
167
|
+
chapters: list[BookChapter] = []
|
|
168
|
+
processed: list[dict[str, Any]] = []
|
|
169
|
+
|
|
170
|
+
for order, md_path in enumerate(md_files):
|
|
171
|
+
result = proc.process_file(md_path)
|
|
172
|
+
if not result:
|
|
173
|
+
continue
|
|
174
|
+
result["content"] = rewrite_chapter_img_paths(
|
|
175
|
+
result["content"], md_path, book_root
|
|
176
|
+
)
|
|
177
|
+
html_name = chapter_html_name(md_path)
|
|
178
|
+
chapters.append(
|
|
179
|
+
BookChapter(
|
|
180
|
+
md_path=md_path,
|
|
181
|
+
html_name=html_name,
|
|
182
|
+
title=result["metadata"]["title"],
|
|
183
|
+
order=order,
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
processed.append({**result, "html_name": html_name})
|
|
187
|
+
|
|
188
|
+
page_counts = [count_pages(item["content"]) for item in processed]
|
|
189
|
+
|
|
190
|
+
built: list[str] = []
|
|
191
|
+
for i, item in enumerate(processed):
|
|
192
|
+
ch = chapters[i]
|
|
193
|
+
prev_ch = chapters[i - 1] if i > 0 else None
|
|
194
|
+
next_ch = chapters[i + 1] if i + 1 < len(chapters) else None
|
|
195
|
+
html = renderer.render_post(
|
|
196
|
+
item,
|
|
197
|
+
book_mode=True,
|
|
198
|
+
prev_href=prev_ch.html_name if prev_ch else None,
|
|
199
|
+
prev_title=prev_ch.title if prev_ch else None,
|
|
200
|
+
next_href=next_ch.html_name if next_ch else None,
|
|
201
|
+
next_title=next_ch.title if next_ch else None,
|
|
202
|
+
prev_chapter_pages=page_counts[i - 1] if i > 0 else 0,
|
|
203
|
+
)
|
|
204
|
+
out_file = out_dir / ch.html_name
|
|
205
|
+
out_file.write_text(html, encoding="utf-8")
|
|
206
|
+
built.append(str(out_file))
|
|
207
|
+
|
|
208
|
+
index_html = renderer.render_book_index(
|
|
209
|
+
[
|
|
210
|
+
{"title": c.title, "href": c.html_name, "source": str(c.md_path)}
|
|
211
|
+
for c in chapters
|
|
212
|
+
],
|
|
213
|
+
site_title=site_title,
|
|
214
|
+
)
|
|
215
|
+
index_path = out_dir / "index.html"
|
|
216
|
+
index_path.write_text(index_html, encoding="utf-8")
|
|
217
|
+
built.insert(0, str(index_path))
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
"root": str(book_root),
|
|
221
|
+
"output_dir": str(out_dir),
|
|
222
|
+
"lang": lang,
|
|
223
|
+
"count": len(chapters),
|
|
224
|
+
"built": built,
|
|
225
|
+
"index": str(index_path),
|
|
226
|
+
}
|
xuanxin/builder.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Build static HTML site from Markdown blog files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from xuanxin.includes import find_peanut_config
|
|
11
|
+
from xuanxin.processor import MarkdownProcessor
|
|
12
|
+
from xuanxin.renderer import BlogRenderer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class BlogBuilder:
|
|
17
|
+
"""Scan a content directory and emit a static HTML blog."""
|
|
18
|
+
|
|
19
|
+
content_dir: Path
|
|
20
|
+
output_dir: Path
|
|
21
|
+
site_title: str = "Blog"
|
|
22
|
+
site_description: str = ""
|
|
23
|
+
base_url: str = ""
|
|
24
|
+
theme: str = "default"
|
|
25
|
+
custom_css: Path | None = None
|
|
26
|
+
pattern: str = "**/*.md"
|
|
27
|
+
include_drafts: bool = False
|
|
28
|
+
mathjax: bool = True
|
|
29
|
+
processor: MarkdownProcessor = field(default_factory=MarkdownProcessor)
|
|
30
|
+
|
|
31
|
+
def build(self) -> dict[str, Any]:
|
|
32
|
+
self.content_dir = Path(self.content_dir)
|
|
33
|
+
self.output_dir = Path(self.output_dir)
|
|
34
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
|
|
36
|
+
posts_dir = self.output_dir / "posts"
|
|
37
|
+
posts_dir.mkdir(exist_ok=True)
|
|
38
|
+
|
|
39
|
+
renderer = BlogRenderer(
|
|
40
|
+
site_title=self.site_title,
|
|
41
|
+
site_description=self.site_description,
|
|
42
|
+
base_url=self.base_url,
|
|
43
|
+
theme=self.theme,
|
|
44
|
+
custom_css=self.custom_css,
|
|
45
|
+
mathjax=self.mathjax,
|
|
46
|
+
)
|
|
47
|
+
renderer.copy_static_assets(self.output_dir, self.custom_css)
|
|
48
|
+
|
|
49
|
+
posts: list[dict[str, Any]] = []
|
|
50
|
+
built: list[str] = []
|
|
51
|
+
skipped: list[str] = []
|
|
52
|
+
|
|
53
|
+
for md_path in sorted(self.content_dir.glob(self.pattern)):
|
|
54
|
+
if md_path.name.startswith("."):
|
|
55
|
+
continue
|
|
56
|
+
result = self.processor.process_file(md_path)
|
|
57
|
+
if not result:
|
|
58
|
+
skipped.append(str(md_path))
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
meta = result["metadata"]
|
|
62
|
+
if meta.get("draft") and not self.include_drafts:
|
|
63
|
+
skipped.append(str(md_path))
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
posts.append(result)
|
|
67
|
+
out_file = posts_dir / f"{meta['slug']}.html"
|
|
68
|
+
out_file.write_text(renderer.render_post(result), encoding="utf-8")
|
|
69
|
+
built.append(str(out_file))
|
|
70
|
+
|
|
71
|
+
index_html = renderer.render_index(posts)
|
|
72
|
+
(self.output_dir / "index.html").write_text(index_html, encoding="utf-8")
|
|
73
|
+
|
|
74
|
+
manifest = {
|
|
75
|
+
"site_title": self.site_title,
|
|
76
|
+
"posts": [
|
|
77
|
+
{
|
|
78
|
+
"title": p["metadata"]["title"],
|
|
79
|
+
"slug": p["metadata"]["slug"],
|
|
80
|
+
"date": p["metadata"]["date"].isoformat(),
|
|
81
|
+
"source": p.get("source_file"),
|
|
82
|
+
}
|
|
83
|
+
for p in posts
|
|
84
|
+
],
|
|
85
|
+
}
|
|
86
|
+
(self.output_dir / "manifest.json").write_text(
|
|
87
|
+
json.dumps(manifest, indent=2), encoding="utf-8"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return {"built": built, "skipped": skipped, "count": len(built)}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def render_file(
|
|
94
|
+
md_path: Path | str,
|
|
95
|
+
*,
|
|
96
|
+
output_dir: Path | None = None,
|
|
97
|
+
site_title: str = "",
|
|
98
|
+
theme: str = "default",
|
|
99
|
+
custom_css: Path | None = None,
|
|
100
|
+
mathjax: bool = True,
|
|
101
|
+
include_config: Path | str | None = None,
|
|
102
|
+
processor: MarkdownProcessor | None = None,
|
|
103
|
+
) -> Path:
|
|
104
|
+
"""Render one Markdown file to {stem}.html in the current directory."""
|
|
105
|
+
md_path = Path(md_path).resolve()
|
|
106
|
+
if not md_path.exists():
|
|
107
|
+
raise FileNotFoundError(md_path)
|
|
108
|
+
|
|
109
|
+
out_dir = Path(output_dir or Path.cwd()).resolve()
|
|
110
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
out_file = out_dir / f"{md_path.stem}.html"
|
|
112
|
+
|
|
113
|
+
cfg = include_config or find_peanut_config(md_path.parent)
|
|
114
|
+
proc = processor or MarkdownProcessor(include_config=cfg)
|
|
115
|
+
result = proc.process_file(md_path)
|
|
116
|
+
if not result:
|
|
117
|
+
raise RuntimeError(f"Failed to process {md_path}")
|
|
118
|
+
|
|
119
|
+
renderer = BlogRenderer(
|
|
120
|
+
site_title=site_title,
|
|
121
|
+
base_url="",
|
|
122
|
+
theme=theme,
|
|
123
|
+
custom_css=custom_css,
|
|
124
|
+
mathjax=mathjax,
|
|
125
|
+
)
|
|
126
|
+
renderer.copy_static_assets(out_dir, custom_css)
|
|
127
|
+
out_file.write_text(renderer.render_post(result), encoding="utf-8")
|
|
128
|
+
return out_file
|
xuanxin/cli.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CLI for xuanxin — build static HTML from Markdown."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from xuanxin import __version__
|
|
11
|
+
from xuanxin.book import default_book_output_dir, discover_book_repo_root, render_book
|
|
12
|
+
from xuanxin.builder import BlogBuilder, render_file
|
|
13
|
+
from xuanxin.processor import MarkdownProcessor
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cmd_build(args: argparse.Namespace) -> int:
|
|
17
|
+
custom_css = Path(args.css) if args.css else None
|
|
18
|
+
if custom_css and not custom_css.exists():
|
|
19
|
+
print(f"Warning: custom CSS not found: {custom_css}", file=sys.stderr)
|
|
20
|
+
|
|
21
|
+
builder = BlogBuilder(
|
|
22
|
+
content_dir=Path(args.input),
|
|
23
|
+
output_dir=Path(args.output),
|
|
24
|
+
site_title=args.title,
|
|
25
|
+
site_description=args.description,
|
|
26
|
+
base_url=args.base_url,
|
|
27
|
+
theme=args.theme,
|
|
28
|
+
custom_css=custom_css,
|
|
29
|
+
pattern=args.pattern,
|
|
30
|
+
include_drafts=args.include_drafts,
|
|
31
|
+
mathjax=not args.no_mathjax,
|
|
32
|
+
)
|
|
33
|
+
result = builder.build()
|
|
34
|
+
print(f"Built {result['count']} post(s) → {args.output}")
|
|
35
|
+
for path in result["built"]:
|
|
36
|
+
print(f" ✓ {path}")
|
|
37
|
+
if result["skipped"]:
|
|
38
|
+
print(f"Skipped {len(result['skipped'])} file(s)")
|
|
39
|
+
return 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cmd_render(args: argparse.Namespace) -> int:
|
|
43
|
+
"""Render one .md file, or the full book with --book."""
|
|
44
|
+
custom_css = Path(args.css) if args.css else None
|
|
45
|
+
if custom_css and not custom_css.exists():
|
|
46
|
+
print(f"Warning: custom CSS not found: {custom_css}", file=sys.stderr)
|
|
47
|
+
|
|
48
|
+
include_config = Path(args.config) if args.config else None
|
|
49
|
+
if include_config and not include_config.exists():
|
|
50
|
+
print(f"Warning: config not found: {include_config}", file=sys.stderr)
|
|
51
|
+
|
|
52
|
+
if args.book:
|
|
53
|
+
root = Path(args.root) if args.root else discover_book_repo_root()
|
|
54
|
+
out_dir = (
|
|
55
|
+
Path(args.output)
|
|
56
|
+
if args.output
|
|
57
|
+
else default_book_output_dir(root, args.lang)
|
|
58
|
+
)
|
|
59
|
+
result = render_book(
|
|
60
|
+
root,
|
|
61
|
+
output_dir=out_dir,
|
|
62
|
+
lang=args.lang,
|
|
63
|
+
site_title=args.title,
|
|
64
|
+
theme=args.theme,
|
|
65
|
+
custom_css=custom_css,
|
|
66
|
+
mathjax=not args.no_mathjax,
|
|
67
|
+
include_config=include_config,
|
|
68
|
+
)
|
|
69
|
+
print(f"Built {result['count']} chapter(s) → {result['output_dir']}")
|
|
70
|
+
for path in result["built"]:
|
|
71
|
+
print(f" ✓ {path}")
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
if not args.file:
|
|
75
|
+
print("error: provide a markdown file or use --book", file=sys.stderr)
|
|
76
|
+
return 1
|
|
77
|
+
|
|
78
|
+
out_dir = Path(args.output) if args.output else Path.cwd()
|
|
79
|
+
out_file = render_file(
|
|
80
|
+
Path(args.file),
|
|
81
|
+
output_dir=out_dir,
|
|
82
|
+
site_title=args.title,
|
|
83
|
+
theme=args.theme,
|
|
84
|
+
custom_css=custom_css,
|
|
85
|
+
mathjax=not args.no_mathjax,
|
|
86
|
+
include_config=include_config,
|
|
87
|
+
)
|
|
88
|
+
print(f"✓ {out_file}")
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def cmd_preview(args: argparse.Namespace) -> int:
|
|
93
|
+
"""Convert a single markdown file and print HTML body to stdout."""
|
|
94
|
+
processor = MarkdownProcessor()
|
|
95
|
+
result = processor.process_file(Path(args.file))
|
|
96
|
+
if not result:
|
|
97
|
+
print("Failed to process file", file=sys.stderr)
|
|
98
|
+
return 1
|
|
99
|
+
print(result["content"])
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def cmd_themes(_args: argparse.Namespace) -> int:
|
|
104
|
+
from xuanxin.renderer import DEFAULT_THEME_DIR
|
|
105
|
+
|
|
106
|
+
print("Available themes:")
|
|
107
|
+
for css in sorted(DEFAULT_THEME_DIR.glob("*.css")):
|
|
108
|
+
if css.name.startswith("_"):
|
|
109
|
+
continue
|
|
110
|
+
print(f" {css.stem}")
|
|
111
|
+
print("\nCustomize: copy a theme file, edit :root variables, pass --css your-theme.css")
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main(argv: list[str] | None = None) -> int:
|
|
116
|
+
parser = argparse.ArgumentParser(
|
|
117
|
+
prog="xuanxin",
|
|
118
|
+
description="Write Markdown blogs, generate beautiful static HTML.",
|
|
119
|
+
)
|
|
120
|
+
parser.add_argument("--version", action="version", version=f"xuanxin {__version__}")
|
|
121
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
122
|
+
|
|
123
|
+
build = sub.add_parser("build", help="Build static site from Markdown files")
|
|
124
|
+
build.add_argument("-i", "--input", default="content", help="Content directory (default: content)")
|
|
125
|
+
build.add_argument("-o", "--output", default="dist", help="Output directory (default: dist)")
|
|
126
|
+
build.add_argument("-t", "--title", default="Blog", help="Site title")
|
|
127
|
+
build.add_argument("-d", "--description", default="", help="Site description")
|
|
128
|
+
build.add_argument("--base-url", default="", help="Base URL prefix for links (e.g. /blog)")
|
|
129
|
+
build.add_argument("--theme", default="default", help="Built-in theme name (default, dark, minimal)")
|
|
130
|
+
build.add_argument("--css", default="", help="Path to custom CSS file (overrides theme vars)")
|
|
131
|
+
build.add_argument("--pattern", default="**/*.md", help="Glob pattern for markdown files")
|
|
132
|
+
build.add_argument("--include-drafts", action="store_true", help="Include draft posts")
|
|
133
|
+
build.add_argument("--no-mathjax", action="store_true", help="Disable MathJax for LaTeX")
|
|
134
|
+
build.set_defaults(func=cmd_build)
|
|
135
|
+
|
|
136
|
+
render = sub.add_parser(
|
|
137
|
+
"render",
|
|
138
|
+
help="Render one .md file, or the full book with --book",
|
|
139
|
+
)
|
|
140
|
+
render.add_argument(
|
|
141
|
+
"file",
|
|
142
|
+
nargs="?",
|
|
143
|
+
default="",
|
|
144
|
+
help="Markdown file path (omit with --book)",
|
|
145
|
+
)
|
|
146
|
+
render.add_argument(
|
|
147
|
+
"--book",
|
|
148
|
+
action="store_true",
|
|
149
|
+
help="Render full book (preface, chapters 1–12, appendix) to book_html_{lang}/",
|
|
150
|
+
)
|
|
151
|
+
render.add_argument(
|
|
152
|
+
"--lang",
|
|
153
|
+
default="zh",
|
|
154
|
+
help="Book language suffix: en, zh, tc, jp, sp (default: zh)",
|
|
155
|
+
)
|
|
156
|
+
render.add_argument(
|
|
157
|
+
"--root",
|
|
158
|
+
default="",
|
|
159
|
+
help="Book repository root (default: auto-detect from cwd)",
|
|
160
|
+
)
|
|
161
|
+
render.add_argument(
|
|
162
|
+
"-o",
|
|
163
|
+
"--output",
|
|
164
|
+
default="",
|
|
165
|
+
help="Output directory (default: book_html/ or book_html_{lang}/ for --book)",
|
|
166
|
+
)
|
|
167
|
+
render.add_argument(
|
|
168
|
+
"-t",
|
|
169
|
+
"--title",
|
|
170
|
+
default="",
|
|
171
|
+
help="Site title (optional; shown in header and page title suffix)",
|
|
172
|
+
)
|
|
173
|
+
render.add_argument(
|
|
174
|
+
"--config",
|
|
175
|
+
default="",
|
|
176
|
+
help="Path to peanut.config for conditional includes (default: auto-detect)",
|
|
177
|
+
)
|
|
178
|
+
render.add_argument("--theme", default="default", help="Built-in theme (default, dark, minimal)")
|
|
179
|
+
render.add_argument("--css", default="", help="Path to custom CSS file")
|
|
180
|
+
render.add_argument("--no-mathjax", action="store_true", help="Disable MathJax for LaTeX")
|
|
181
|
+
render.set_defaults(func=cmd_render)
|
|
182
|
+
|
|
183
|
+
preview = sub.add_parser("preview", help="Print HTML body for one markdown file")
|
|
184
|
+
preview.add_argument("file", help="Markdown file path")
|
|
185
|
+
preview.set_defaults(func=cmd_preview)
|
|
186
|
+
|
|
187
|
+
themes = sub.add_parser("themes", help="List available built-in themes")
|
|
188
|
+
themes.set_defaults(func=cmd_themes)
|
|
189
|
+
|
|
190
|
+
args = parser.parse_args(argv)
|
|
191
|
+
return args.func(args)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
sys.exit(main())
|