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 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())