#!/usr/bin/env python3 import os import subprocess import argparse import json from typing import List, Dict, Any from pathlib import Path def export_html_wasm(notebook_path: str, output_dir: str, as_app: bool = False) -> bool: """Export a single marimo notebook to HTML format. Returns: bool: True if export succeeded, False otherwise """ output_path = notebook_path.replace(".py", ".html") cmd = ["marimo", "export", "html-wasm"] if as_app: print(f"Exporting {notebook_path} to {output_path} as app") cmd.extend(["--mode", "run", "--no-show-code"]) else: print(f"Exporting {notebook_path} to {output_path} as notebook") cmd.extend(["--mode", "edit"]) try: output_file = os.path.join(output_dir, output_path) os.makedirs(os.path.dirname(output_file), exist_ok=True) cmd.extend([notebook_path, "-o", output_file]) subprocess.run(cmd, capture_output=True, text=True, check=True) return True except subprocess.CalledProcessError as e: print(f"Error exporting {notebook_path}:") print(e.stderr) return False except Exception as e: print(f"Unexpected error exporting {notebook_path}: {e}") return False def get_course_metadata(course_dir: Path) -> Dict[str, Any]: """Extract metadata from a course directory.""" metadata = { "id": course_dir.name, "title": course_dir.name.replace("_", " ").title(), "description": "", "notebooks": [] } # Try to read README.md for description readme_path = course_dir / "README.md" if readme_path.exists(): with open(readme_path, "r", encoding="utf-8") as f: content = f.read() # Extract first paragraph as description if content: lines = content.split("\n") # Skip title line if it exists start_idx = 1 if lines and lines[0].startswith("#") else 0 description_lines = [] for line in lines[start_idx:]: if line.strip() and not line.startswith("#"): description_lines.append(line) elif description_lines: # Stop at the next heading break metadata["description"] = " ".join(description_lines).strip() return metadata def organize_notebooks_by_course(all_notebooks: List[str]) -> Dict[str, Dict[str, Any]]: """Organize notebooks by course.""" courses = {} for notebook_path in all_notebooks: path = Path(notebook_path) course_id = path.parts[0] if course_id not in courses: course_dir = Path(course_id) courses[course_id] = get_course_metadata(course_dir) # Extract notebook info filename = path.name notebook_id = path.stem # Try to extract order from filename (e.g., 001_numbers.py -> 1) order = 999 if "_" in notebook_id: try: order_str = notebook_id.split("_")[0] order = int(order_str) except ValueError: pass # Create display name by removing order prefix and underscores display_name = notebook_id if "_" in notebook_id: display_name = "_".join(notebook_id.split("_")[1:]) display_name = display_name.replace("_", " ").title() courses[course_id]["notebooks"].append({ "id": notebook_id, "path": notebook_path, "display_name": display_name, "order": order }) # Sort notebooks by order for course_id in courses: courses[course_id]["notebooks"].sort(key=lambda x: x["order"]) return courses def generate_eva_css() -> str: """Generate Neon Genesis Evangelion inspired CSS.""" return """ :root { --eva-purple: #9a1eb3; --eva-green: #00ff00; --eva-orange: #ff6600; --eva-blue: #0066ff; --eva-red: #ff0000; --eva-black: #111111; --eva-dark: #222222; --eva-terminal-bg: rgba(0, 0, 0, 0.85); --eva-text: #e0e0e0; --eva-border-radius: 4px; --eva-transition: all 0.3s ease; } body { background-color: var(--eva-black); color: var(--eva-text); font-family: 'Courier New', monospace; margin: 0; padding: 0; line-height: 1.6; } .eva-container { max-width: 1200px; margin: 0 auto; padding: 2rem; } .eva-header { border-bottom: 2px solid var(--eva-green); padding-bottom: 1rem; margin-bottom: 2rem; display: flex; justify-content: space-between; align-items: center; position: sticky; top: 0; background-color: rgba(17, 17, 17, 0.95); z-index: 100; backdrop-filter: blur(5px); padding-top: 1rem; } .eva-logo { font-size: 2.5rem; font-weight: bold; color: var(--eva-green); text-transform: uppercase; letter-spacing: 2px; text-shadow: 0 0 10px rgba(0, 255, 0, 0.5); } .eva-nav { display: flex; gap: 1.5rem; } .eva-nav a { color: white; text-decoration: none; text-transform: uppercase; font-size: 0.9rem; letter-spacing: 1px; transition: color 0.3s; position: relative; padding: 0.5rem 0; } .eva-nav a:hover { color: var(--eva-green); } .eva-nav a:hover::after { content: ''; position: absolute; bottom: -5px; left: 0; width: 100%; height: 2px; background-color: var(--eva-green); animation: scanline 1.5s linear infinite; } .eva-hero { background-color: var(--eva-terminal-bg); border: 1px solid var(--eva-green); padding: 3rem 2rem; margin-bottom: 3rem; position: relative; overflow: hidden; border-radius: var(--eva-border-radius); display: flex; flex-direction: column; align-items: flex-start; background-image: linear-gradient(45deg, rgba(0, 0, 0, 0.9), rgba(0, 0, 0, 0.7)), url('https://raw.githubusercontent.com/marimo-team/marimo/main/docs/_static/marimo-logotype-thick.svg'); background-size: cover; background-position: center; background-blend-mode: overlay; } .eva-hero::before { content: ''; position: absolute; top: 0; left: 0; width: 100%; height: 2px; background-color: var(--eva-green); animation: scanline 3s linear infinite; } .eva-hero h1 { font-size: 2.5rem; margin-bottom: 1rem; color: var(--eva-green); text-transform: uppercase; letter-spacing: 2px; text-shadow: 0 0 10px rgba(0, 255, 0, 0.5); } .eva-hero p { font-size: 1.1rem; max-width: 800px; margin-bottom: 2rem; line-height: 1.8; } .eva-features { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 2rem; margin-bottom: 3rem; } .eva-feature { background-color: var(--eva-terminal-bg); border: 1px solid var(--eva-blue); padding: 1.5rem; border-radius: var(--eva-border-radius); transition: var(--eva-transition); position: relative; overflow: hidden; } .eva-feature:hover { transform: translateY(-5px); box-shadow: 0 10px 20px rgba(0, 102, 255, 0.2); } .eva-feature-icon { font-size: 2rem; margin-bottom: 1rem; color: var(--eva-blue); } .eva-feature h3 { font-size: 1.3rem; margin-bottom: 1rem; color: var(--eva-blue); } .eva-section-title { font-size: 2rem; color: var(--eva-green); margin-bottom: 2rem; text-transform: uppercase; letter-spacing: 2px; text-align: center; position: relative; padding-bottom: 1rem; } .eva-section-title::after { content: ''; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); width: 100px; height: 2px; background-color: var(--eva-green); } .eva-courses { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 2rem; } .eva-course { background-color: var(--eva-terminal-bg); border: 1px solid var(--eva-purple); border-radius: var(--eva-border-radius); transition: var(--eva-transition); position: relative; overflow: hidden; } .eva-course:hover { transform: translateY(-5px); box-shadow: 0 10px 20px rgba(154, 30, 179, 0.3); } .eva-course::after { content: ''; position: absolute; bottom: 0; left: 0; width: 100%; height: 2px; background-color: var(--eva-purple); animation: scanline 2s linear infinite; } .eva-course-header { padding: 1.5rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid rgba(154, 30, 179, 0.3); } .eva-course-title { font-size: 1.5rem; color: var(--eva-purple); text-transform: uppercase; letter-spacing: 1px; margin: 0; } .eva-course-toggle { color: var(--eva-purple); font-size: 1.5rem; transition: var(--eva-transition); } .eva-course-content { padding: 0 1.5rem; max-height: 0; overflow: hidden; transition: var(--eva-transition); } .eva-course.active .eva-course-content { padding: 1.5rem; max-height: 1000px; } .eva-course.active .eva-course-toggle { transform: rotate(180deg); } .eva-course-description { margin-bottom: 1.5rem; font-size: 0.9rem; line-height: 1.6; } .eva-notebooks { margin-top: 1rem; } .eva-notebook { margin-bottom: 0.75rem; padding: 0.5rem; border-left: 2px solid var(--eva-blue); transition: var(--eva-transition); display: flex; align-items: center; } .eva-notebook:hover { background-color: rgba(0, 102, 255, 0.1); padding-left: 1rem; } .eva-notebook a { color: white; text-decoration: none; display: block; font-size: 0.9rem; flex-grow: 1; } .eva-notebook a:hover { color: var(--eva-blue); } .eva-notebook-number { color: var(--eva-blue); font-size: 0.8rem; margin-right: 0.5rem; opacity: 0.7; min-width: 24px; } .eva-button { display: inline-block; background-color: transparent; color: var(--eva-green); border: 1px solid var(--eva-green); padding: 0.7rem 1.5rem; text-decoration: none; text-transform: uppercase; font-size: 0.9rem; letter-spacing: 1px; transition: var(--eva-transition); cursor: pointer; border-radius: var(--eva-border-radius); position: relative; overflow: hidden; } .eva-button:hover { background-color: var(--eva-green); color: var(--eva-black); } .eva-button::after { content: ''; position: absolute; top: 0; left: -100%; width: 100%; height: 100%; background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); transition: 0.5s; } .eva-button:hover::after { left: 100%; } .eva-cta { background-color: var(--eva-terminal-bg); border: 1px solid var(--eva-orange); padding: 3rem 2rem; margin: 4rem 0; text-align: center; border-radius: var(--eva-border-radius); position: relative; overflow: hidden; } .eva-cta h2 { font-size: 2rem; color: var(--eva-orange); margin-bottom: 1.5rem; text-transform: uppercase; } .eva-cta p { max-width: 600px; margin: 0 auto 2rem; font-size: 1.1rem; } .eva-cta .eva-button { color: var(--eva-orange); border-color: var(--eva-orange); } .eva-cta .eva-button:hover { background-color: var(--eva-orange); color: var(--eva-black); } .eva-footer { margin-top: 4rem; padding-top: 2rem; border-top: 2px solid var(--eva-green); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 2rem; } .eva-footer-links { display: flex; gap: 1.5rem; } .eva-footer-links a { color: var(--eva-text); text-decoration: none; transition: var(--eva-transition); } .eva-footer-links a:hover { color: var(--eva-green); } .eva-footer-copyright { font-size: 0.9rem; } .eva-search { position: relative; margin-bottom: 3rem; } .eva-search input { width: 100%; padding: 1rem; background-color: var(--eva-terminal-bg); border: 1px solid var(--eva-green); color: var(--eva-text); font-family: 'Courier New', monospace; font-size: 1rem; border-radius: var(--eva-border-radius); outline: none; transition: var(--eva-transition); } .eva-search input:focus { box-shadow: 0 0 10px rgba(0, 255, 0, 0.3); } .eva-search input::placeholder { color: rgba(224, 224, 224, 0.5); } .eva-search-icon { position: absolute; right: 1rem; top: 50%; transform: translateY(-50%); color: var(--eva-green); font-size: 1.2rem; } @keyframes scanline { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } } @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } .eva-cursor { display: inline-block; width: 10px; height: 1.2em; background-color: var(--eva-green); margin-left: 2px; animation: blink 1s infinite; vertical-align: middle; } @media (max-width: 768px) { .eva-courses { grid-template-columns: 1fr; } .eva-header { flex-direction: column; align-items: flex-start; padding: 1rem; } .eva-nav { margin-top: 1rem; flex-wrap: wrap; } .eva-hero { padding: 2rem 1rem; } .eva-hero h1 { font-size: 2rem; } .eva-features { grid-template-columns: 1fr; } .eva-footer { flex-direction: column; align-items: center; text-align: center; } } """ def generate_index(courses: Dict[str, Dict[str, Any]], output_dir: str) -> None: """Generate the index.html file with Neon Genesis Evangelion aesthetics.""" print("Generating index.html") index_path = os.path.join(output_dir, "index.html") os.makedirs(output_dir, exist_ok=True) try: with open(index_path, "w", encoding="utf-8") as f: f.write( """ Marimo Learn - Interactive Educational Notebooks

Interactive Learning with Marimo

A curated collection of educational notebooks covering computer science, mathematics, data science, and more. Built with marimo - the reactive Python notebook that makes data exploration delightful.

Explore Courses

Why Marimo Learn?

Reactive Notebooks

Experience the power of reactive programming with marimo notebooks that automatically update when dependencies change.

Learn by Doing

Interactive examples and exercises help you understand concepts through hands-on practice.

Comprehensive Courses

From Python basics to advanced optimization techniques, our courses cover a wide range of topics.

Explore Courses

""" ) # Sort courses alphabetically sorted_courses = sorted(courses.values(), key=lambda x: x["title"]) for course in sorted_courses: # Skip if no notebooks if not course["notebooks"]: continue f.write( f'
\n' f'
\n' f'

{course["title"]}

\n' f' \n' f'
\n' f'
\n' f'

{course["description"]}

\n' f'
\n' ) for i, notebook in enumerate(course["notebooks"]): notebook_number = f"{i+1:02d}" f.write( f'
\n' f' {notebook_number}\n' f' {notebook["display_name"]}\n' f'
\n' ) f.write( f'
\n' f'
\n' f'
\n' ) f.write( """

Contribute to Marimo Learn

Help us expand our collection of educational notebooks. Whether you're an expert in machine learning, statistics, or any other field, your contributions are welcome!

Contribute on GitHub
""" ) except IOError as e: print(f"Error generating index.html: {e}") def main() -> None: parser = argparse.ArgumentParser(description="Build marimo notebooks") parser.add_argument( "--output-dir", default="_site", help="Output directory for built files" ) parser.add_argument( "--course-dirs", nargs="+", default=None, help="Specific course directories to build (default: all directories with .py files)" ) args = parser.parse_args() # Find all course directories (directories containing .py files) all_notebooks: List[str] = [] # Directories to exclude from course detection excluded_dirs = ["scripts", "env", "__pycache__", ".git", ".github", "assets"] if args.course_dirs: course_dirs = args.course_dirs else: # Automatically detect course directories (any directory with .py files) course_dirs = [] for item in os.listdir("."): if (os.path.isdir(item) and not item.startswith(".") and not item.startswith("_") and item not in excluded_dirs): # Check if directory contains .py files if list(Path(item).glob("*.py")): course_dirs.append(item) print(f"Found course directories: {', '.join(course_dirs)}") for directory in course_dirs: dir_path = Path(directory) if not dir_path.exists(): print(f"Warning: Directory not found: {dir_path}") continue notebooks = [str(path) for path in dir_path.rglob("*.py") if not path.name.startswith("_") and "/__pycache__/" not in str(path)] all_notebooks.extend(notebooks) if not all_notebooks: print("No notebooks found!") return # Export notebooks sequentially successful_notebooks = [] for nb in all_notebooks: # Determine if notebook should be exported as app or notebook # For now, export all as notebooks if export_html_wasm(nb, args.output_dir, as_app=False): successful_notebooks.append(nb) # Organize notebooks by course (only include successfully exported notebooks) courses = organize_notebooks_by_course(successful_notebooks) # Generate index with organized courses generate_index(courses, args.output_dir) # Save course data as JSON for potential use by other tools courses_json_path = os.path.join(args.output_dir, "courses.json") with open(courses_json_path, "w", encoding="utf-8") as f: json.dump(courses, f, indent=2) print(f"Build complete! Site generated in {args.output_dir}") print(f"Successfully exported {len(successful_notebooks)} out of {len(all_notebooks)} notebooks") if __name__ == "__main__": main()