|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import html |
| 4 | +import os |
| 5 | +import re |
| 6 | +import shutil |
| 7 | +import stat |
| 8 | +import subprocess |
| 9 | +import sys |
| 10 | +from pathlib import Path |
| 11 | + |
| 12 | + |
| 13 | +ROOT = Path(__file__).resolve().parent |
| 14 | +OUTPUT_DIR = Path(os.environ.get("PAGES_OUTPUT_DIR", ROOT / "_site")) |
| 15 | +EXCLUDED_PARTS = { |
| 16 | + ".git", |
| 17 | + ".github", |
| 18 | + ".ipynb_checkpoints", |
| 19 | + "__pycache__", |
| 20 | + "_site", |
| 21 | +} |
| 22 | +STATIC_SUFFIXES = { |
| 23 | + ".png", |
| 24 | + ".jpg", |
| 25 | + ".jpeg", |
| 26 | + ".gif", |
| 27 | + ".svg", |
| 28 | + ".webp", |
| 29 | + ".ico", |
| 30 | + ".txt", |
| 31 | + ".yml", |
| 32 | + ".yaml", |
| 33 | +} |
| 34 | + |
| 35 | + |
| 36 | +def should_skip(path: Path) -> bool: |
| 37 | + return any(part in EXCLUDED_PARTS for part in path.parts) |
| 38 | + |
| 39 | + |
| 40 | +def handle_remove_readonly(func, path, excinfo) -> None: |
| 41 | + os.chmod(path, stat.S_IWRITE) |
| 42 | + func(path) |
| 43 | + |
| 44 | + |
| 45 | +def clear_output_dir() -> None: |
| 46 | + if OUTPUT_DIR.exists(): |
| 47 | + shutil.rmtree(OUTPUT_DIR, onexc=handle_remove_readonly) |
| 48 | + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) |
| 49 | + |
| 50 | + |
| 51 | +def run_nbconvert(notebook_path: Path) -> Path: |
| 52 | + relative_path = notebook_path.relative_to(ROOT) |
| 53 | + destination = OUTPUT_DIR / relative_path.with_suffix(".html") |
| 54 | + destination.parent.mkdir(parents=True, exist_ok=True) |
| 55 | + |
| 56 | + command = [ |
| 57 | + sys.executable, |
| 58 | + "-m", |
| 59 | + "nbconvert", |
| 60 | + "--to", |
| 61 | + "html", |
| 62 | + "--output", |
| 63 | + destination.stem, |
| 64 | + "--output-dir", |
| 65 | + str(destination.parent), |
| 66 | + str(notebook_path), |
| 67 | + ] |
| 68 | + subprocess.run(command, check=True) |
| 69 | + rewrite_html_links(destination) |
| 70 | + return destination |
| 71 | + |
| 72 | + |
| 73 | +def rewrite_notebook_links(text: str) -> str: |
| 74 | + return re.sub(r"(?P<prefix>[\(/\"'=])(?P<target>[^\"')>]+?)\.ipynb(?P<suffix>[\"')>#?])", r"\g<prefix>\g<target>.html\g<suffix>", text) |
| 75 | + |
| 76 | + |
| 77 | +def rewrite_html_links(html_path: Path) -> None: |
| 78 | + content = html_path.read_text(encoding="utf-8") |
| 79 | + updated = rewrite_notebook_links(content) |
| 80 | + if updated != content: |
| 81 | + html_path.write_text(updated, encoding="utf-8") |
| 82 | + |
| 83 | + |
| 84 | +def copy_static_files() -> None: |
| 85 | + for path in ROOT.rglob("*"): |
| 86 | + if path.is_dir() or should_skip(path): |
| 87 | + continue |
| 88 | + if path == ROOT / "README.md": |
| 89 | + continue |
| 90 | + if path.suffix.lower() not in STATIC_SUFFIXES: |
| 91 | + continue |
| 92 | + |
| 93 | + destination = OUTPUT_DIR / path.relative_to(ROOT) |
| 94 | + destination.parent.mkdir(parents=True, exist_ok=True) |
| 95 | + shutil.copy2(path, destination) |
| 96 | + |
| 97 | + |
| 98 | +def render_markdown(markdown_text: str) -> str: |
| 99 | + try: |
| 100 | + import markdown |
| 101 | + except ImportError as exc: |
| 102 | + raise RuntimeError( |
| 103 | + "The 'markdown' package is required to build the Pages homepage. " |
| 104 | + "Install it with 'pip install markdown'." |
| 105 | + ) from exc |
| 106 | + |
| 107 | + return markdown.markdown( |
| 108 | + markdown_text, |
| 109 | + extensions=[ |
| 110 | + "extra", |
| 111 | + "toc", |
| 112 | + "tables", |
| 113 | + "fenced_code", |
| 114 | + "sane_lists", |
| 115 | + ], |
| 116 | + output_format="html5", |
| 117 | + ) |
| 118 | + |
| 119 | + |
| 120 | +def build_homepage() -> None: |
| 121 | + readme_path = ROOT / "README.md" |
| 122 | + readme_text = readme_path.read_text(encoding="utf-8") |
| 123 | + readme_text = rewrite_notebook_links(readme_text) |
| 124 | + body = render_markdown(readme_text) |
| 125 | + |
| 126 | + page = f"""<!DOCTYPE html> |
| 127 | +<html lang="en"> |
| 128 | + <head> |
| 129 | + <meta charset="utf-8"> |
| 130 | + <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 131 | + <title>MathLearningNotes</title> |
| 132 | + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5.8.1/github-markdown.min.css"> |
| 133 | + <style> |
| 134 | + body {{ |
| 135 | + box-sizing: border-box; |
| 136 | + margin: 0; |
| 137 | + background: #f6f8fa; |
| 138 | + color: #1f2328; |
| 139 | + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; |
| 140 | + }} |
| 141 | + .page {{ |
| 142 | + max-width: 980px; |
| 143 | + margin: 0 auto; |
| 144 | + padding: 32px 16px 48px; |
| 145 | + }} |
| 146 | + .markdown-body {{ |
| 147 | + background: #ffffff; |
| 148 | + border: 1px solid #d0d7de; |
| 149 | + border-radius: 12px; |
| 150 | + padding: 32px; |
| 151 | + }} |
| 152 | + .site-note {{ |
| 153 | + margin-bottom: 16px; |
| 154 | + padding: 12px 16px; |
| 155 | + border-left: 4px solid #0969da; |
| 156 | + background: #ddf4ff; |
| 157 | + border-radius: 8px; |
| 158 | + }} |
| 159 | + @media (max-width: 767px) {{ |
| 160 | + .page {{ |
| 161 | + padding: 16px 10px 32px; |
| 162 | + }} |
| 163 | + .markdown-body {{ |
| 164 | + padding: 20px; |
| 165 | + }} |
| 166 | + }} |
| 167 | + </style> |
| 168 | + </head> |
| 169 | + <body> |
| 170 | + <main class="page"> |
| 171 | + <div class="site-note"> |
| 172 | + Notebook links on this site open rendered HTML pages generated from the repository's <code>.ipynb</code> files. |
| 173 | + </div> |
| 174 | + <article class="markdown-body"> |
| 175 | + {body} |
| 176 | + </article> |
| 177 | + </main> |
| 178 | + </body> |
| 179 | +</html> |
| 180 | +""" |
| 181 | + (OUTPUT_DIR / "index.html").write_text(page, encoding="utf-8") |
| 182 | + |
| 183 | + |
| 184 | +def create_nojekyll_file() -> None: |
| 185 | + (OUTPUT_DIR / ".nojekyll").write_text("", encoding="utf-8") |
| 186 | + |
| 187 | + |
| 188 | +def main() -> None: |
| 189 | + clear_output_dir() |
| 190 | + |
| 191 | + notebooks = sorted( |
| 192 | + path for path in ROOT.rglob("*.ipynb") if not should_skip(path) |
| 193 | + ) |
| 194 | + if not notebooks: |
| 195 | + raise RuntimeError("No notebooks were found to convert.") |
| 196 | + |
| 197 | + for notebook in notebooks: |
| 198 | + print(f"Converting {notebook.relative_to(ROOT)}") |
| 199 | + run_nbconvert(notebook) |
| 200 | + |
| 201 | + copy_static_files() |
| 202 | + build_homepage() |
| 203 | + create_nojekyll_file() |
| 204 | + |
| 205 | + print(f"Built {len(notebooks)} notebooks into {OUTPUT_DIR}") |
| 206 | + |
| 207 | + |
| 208 | +if __name__ == "__main__": |
| 209 | + try: |
| 210 | + main() |
| 211 | + except subprocess.CalledProcessError as exc: |
| 212 | + command = " ".join(html.escape(part) for part in exc.cmd) |
| 213 | + raise SystemExit(f"Build failed while running: {command}") from exc |
0 commit comments