Spaces:
Sleeping
Sleeping
#!/usr/bin/env python3 | |
import os | |
import subprocess | |
import argparse | |
import json | |
from typing import List, Dict, Any | |
from pathlib import Path | |
import re | |
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]) | |
print(f"Running command: {' '.join(cmd)}") | |
# Use Popen to handle interactive prompts | |
process = subprocess.Popen( | |
cmd, | |
stdin=subprocess.PIPE, | |
stdout=subprocess.PIPE, | |
stderr=subprocess.PIPE, | |
text=True | |
) | |
# Send 'Y' to the prompt | |
stdout, stderr = process.communicate(input="Y\n", timeout=60) | |
if process.returncode != 0: | |
print(f"Error exporting {notebook_path}:") | |
print(f"Command: {' '.join(cmd)}") | |
print(f"Return code: {process.returncode}") | |
print(f"Stdout: {stdout}") | |
print(f"Stderr: {stderr}") | |
return False | |
print(f"Successfully exported {notebook_path} to {output_file}") | |
return True | |
except subprocess.TimeoutExpired: | |
print(f"Timeout exporting {notebook_path} - command took too long to execute") | |
return False | |
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 | |
description = " ".join(description_lines).strip() | |
# Clean up the description | |
description = description.replace("_", "") | |
description = description.replace("[work in progress]", "") | |
description = description.replace("(https://github.com/marimo-team/learn/issues/51)", "") | |
# Remove any other GitHub issue links | |
description = re.sub(r'\[.*?\]\(https://github\.com/.*?/issues/\d+\)', '', description) | |
description = re.sub(r'https://github\.com/.*?/issues/\d+', '', description) | |
# Clean up any double spaces | |
description = re.sub(r'\s+', ' ', description).strip() | |
metadata["description"] = description | |
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:]) | |
# Convert display name to title case, but handle italics properly | |
parts = display_name.split("_") | |
formatted_parts = [] | |
i = 0 | |
while i < len(parts): | |
if i + 1 < len(parts) and parts[i] == "" and parts[i+1] == "": | |
# Skip empty parts that might come from consecutive underscores | |
i += 2 | |
continue | |
if i + 1 < len(parts) and (parts[i] == "" or parts[i+1] == ""): | |
# This is an italics marker | |
if parts[i] == "": | |
# Opening italics | |
text_part = parts[i+1].replace("_", " ").title() | |
formatted_parts.append(f"<em>{text_part}</em>") | |
i += 2 | |
else: | |
# Text followed by italics marker | |
text_part = parts[i].replace("_", " ").title() | |
formatted_parts.append(text_part) | |
i += 1 | |
else: | |
# Regular text | |
text_part = parts[i].replace("_", " ").title() | |
formatted_parts.append(text_part) | |
i += 1 | |
display_name = " ".join(formatted_parts) | |
courses[course_id]["notebooks"].append({ | |
"id": notebook_id, | |
"path": notebook_path, | |
"display_name": display_name, | |
"order": order, | |
"original_number": notebook_id.split("_")[0] if "_" in notebook_id else "" | |
}) | |
# 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 with light/dark mode support.""" | |
return """ | |
:root { | |
/* Light mode colors (default) */ | |
--eva-purple: #7209b7; | |
--eva-green: #1c7361; | |
--eva-orange: #e65100; | |
--eva-blue: #0039cb; | |
--eva-red: #c62828; | |
--eva-black: #f5f5f5; | |
--eva-dark: #e0e0e0; | |
--eva-terminal-bg: rgba(255, 255, 255, 0.9); | |
--eva-text: #333333; | |
--eva-border-radius: 4px; | |
--eva-transition: all 0.3s ease; | |
} | |
/* Dark mode colors */ | |
[data-theme="dark"] { | |
--eva-purple: #9a1eb3; | |
--eva-green: #1c7361; | |
--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; | |
} | |
body { | |
background-color: var(--eva-black); | |
color: var(--eva-text); | |
font-family: 'Courier New', monospace; | |
margin: 0; | |
padding: 0; | |
line-height: 1.6; | |
transition: background-color 0.3s ease, color 0.3s ease; | |
} | |
.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: var(--eva-black); | |
z-index: 100; | |
backdrop-filter: blur(5px); | |
padding-top: 1rem; | |
transition: background-color 0.3s ease; | |
} | |
[data-theme="light"] .eva-header { | |
background-color: rgba(245, 245, 245, 0.95); | |
} | |
.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(28, 115, 97, 0.5); | |
} | |
[data-theme="light"] .eva-logo { | |
text-shadow: 0 0 10px rgba(28, 115, 97, 0.3); | |
} | |
.eva-nav { | |
display: flex; | |
gap: 1.5rem; | |
align-items: center; | |
} | |
.eva-nav a { | |
color: var(--eva-text); | |
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; | |
} | |
.theme-toggle { | |
background: none; | |
border: none; | |
color: var(--eva-text); | |
cursor: pointer; | |
font-size: 1.2rem; | |
padding: 0.5rem; | |
margin-left: 1rem; | |
transition: color 0.3s; | |
} | |
.theme-toggle:hover { | |
color: var(--eva-green); | |
} | |
.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; | |
transition: background-color 0.3s ease, border-color 0.3s ease; | |
} | |
[data-theme="light"] .eva-hero { | |
background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.7)), url('https://raw.githubusercontent.com/marimo-team/marimo/main/docs/_static/marimo-logotype-thick.svg'); | |
} | |
.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(28, 115, 97, 0.5); | |
} | |
[data-theme="light"] .eva-hero h1 { | |
text-shadow: 0 0 10px rgba(28, 115, 97, 0.3); | |
} | |
.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); | |
} | |
/* Flashcard view for courses */ | |
.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), height 0.4s cubic-bezier(0.19, 1, 0.22, 1); | |
position: relative; | |
overflow: hidden; | |
height: 350px; | |
display: flex; | |
flex-direction: column; | |
} | |
.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-badge { | |
position: absolute; | |
top: 15px; | |
right: -40px; | |
background: linear-gradient(135deg, var(--eva-orange) 0%, #ff9500 100%); | |
color: var(--eva-black); | |
font-size: 0.65rem; | |
padding: 0.3rem 2.5rem; | |
text-transform: uppercase; | |
font-weight: bold; | |
z-index: 3; | |
letter-spacing: 1px; | |
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3); | |
transform: rotate(45deg); | |
text-shadow: 0 1px 1px rgba(255, 255, 255, 0.2); | |
border-top: 1px solid rgba(255, 255, 255, 0.3); | |
border-bottom: 1px solid rgba(0, 0, 0, 0.2); | |
white-space: nowrap; | |
overflow: hidden; | |
} | |
.eva-course-badge i { | |
margin-right: 4px; | |
font-size: 0.7rem; | |
} | |
[data-theme="light"] .eva-course-badge { | |
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); | |
text-shadow: 0 1px 1px rgba(255, 255, 255, 0.4); | |
} | |
.eva-course-badge::before { | |
content: ''; | |
position: absolute; | |
left: 0; | |
top: 0; | |
width: 100%; | |
height: 100%; | |
background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.3), transparent); | |
animation: scanline 2s linear infinite; | |
} | |
.eva-course-header { | |
padding: 1rem 1.5rem; | |
cursor: pointer; | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
border-bottom: 1px solid rgba(154, 30, 179, 0.3); | |
z-index: 2; | |
background-color: var(--eva-terminal-bg); | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 3.5rem; | |
box-sizing: border-box; | |
} | |
.eva-course-title { | |
font-size: 1.3rem; | |
color: var(--eva-purple); | |
text-transform: uppercase; | |
letter-spacing: 1px; | |
margin: 0; | |
} | |
.eva-course-toggle { | |
color: var(--eva-purple); | |
font-size: 1.5rem; | |
transition: transform 0.4s cubic-bezier(0.19, 1, 0.22, 1); | |
} | |
.eva-course.active .eva-course-toggle { | |
transform: rotate(180deg); | |
} | |
.eva-course-front { | |
display: flex; | |
flex-direction: column; | |
justify-content: space-between; | |
padding: 1.5rem; | |
margin-top: 3.5rem; | |
transition: opacity 0.3s ease, transform 0.3s ease; | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: calc(100% - 3.5rem); | |
background-color: var(--eva-terminal-bg); | |
z-index: 1; | |
box-sizing: border-box; | |
} | |
.eva-course.active .eva-course-front { | |
opacity: 0; | |
transform: translateY(-10px); | |
pointer-events: none; | |
} | |
.eva-course-description { | |
margin-top: 0.5rem; | |
margin-bottom: 1.5rem; | |
font-size: 0.9rem; | |
line-height: 1.6; | |
flex-grow: 1; | |
overflow: hidden; | |
display: -webkit-box; | |
-webkit-line-clamp: 4; | |
-webkit-box-orient: vertical; | |
max-height: 150px; | |
} | |
.eva-course-stats { | |
display: flex; | |
justify-content: space-between; | |
font-size: 0.8rem; | |
color: var(--eva-text); | |
opacity: 0.7; | |
} | |
.eva-course-content { | |
position: absolute; | |
top: 3.5rem; | |
left: 0; | |
width: 100%; | |
height: calc(100% - 3.5rem); | |
padding: 1.5rem; | |
background-color: var(--eva-terminal-bg); | |
transition: opacity 0.3s ease, transform 0.3s ease; | |
opacity: 0; | |
transform: translateY(10px); | |
pointer-events: none; | |
overflow-y: auto; | |
z-index: 1; | |
box-sizing: border-box; | |
} | |
.eva-course.active .eva-course-content { | |
opacity: 1; | |
transform: translateY(0); | |
pointer-events: auto; | |
} | |
.eva-course.active { | |
height: auto; | |
min-height: 350px; | |
max-height: 800px; | |
transition: height 0.4s cubic-bezier(0.19, 1, 0.22, 1), transform 0.3s ease, box-shadow 0.3s ease; | |
} | |
.eva-notebooks { | |
margin-top: 1rem; | |
display: grid; | |
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); | |
gap: 0.75rem; | |
} | |
.eva-notebook { | |
margin-bottom: 0.5rem; | |
padding: 0.75rem; | |
border-left: 2px solid var(--eva-blue); | |
transition: all 0.25s ease; | |
display: flex; | |
align-items: center; | |
background-color: rgba(0, 0, 0, 0.2); | |
border-radius: 0 var(--eva-border-radius) var(--eva-border-radius) 0; | |
opacity: 1; | |
transform: translateX(0); | |
} | |
[data-theme="light"] .eva-notebook { | |
background-color: rgba(0, 0, 0, 0.05); | |
} | |
.eva-notebook:hover { | |
background-color: rgba(0, 102, 255, 0.1); | |
padding-left: 1rem; | |
transform: translateX(3px); | |
} | |
.eva-notebook a { | |
color: var(--eva-text); | |
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.75rem; | |
opacity: 0.7; | |
min-width: 24px; | |
font-weight: bold; | |
} | |
.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-course-button { | |
margin-top: 1rem; | |
margin-bottom: 1rem; | |
align-self: center; | |
} | |
.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; | |
flex-direction: column; | |
align-items: center; | |
gap: 2rem; | |
} | |
.eva-footer-logo { | |
max-width: 200px; | |
margin-bottom: 1rem; | |
} | |
.eva-footer-links { | |
display: flex; | |
gap: 1.5rem; | |
margin-bottom: 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-social-links { | |
display: flex; | |
gap: 1.5rem; | |
margin-bottom: 1.5rem; | |
} | |
.eva-social-links a { | |
color: var(--eva-text); | |
font-size: 1.5rem; | |
transition: var(--eva-transition); | |
} | |
.eva-social-links a:hover { | |
color: var(--eva-green); | |
transform: translateY(-3px); | |
} | |
.eva-footer-copyright { | |
font-size: 0.9rem; | |
text-align: center; | |
} | |
.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(28, 115, 97, 0.3); | |
} | |
.eva-search input::placeholder { | |
color: rgba(224, 224, 224, 0.5); | |
} | |
[data-theme="light"] .eva-search input::placeholder { | |
color: rgba(51, 51, 51, 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; | |
} | |
.eva-notebooks { | |
grid-template-columns: 1fr; | |
} | |
} | |
.eva-course.closing .eva-course-content { | |
opacity: 0; | |
transform: translateY(10px); | |
transition: opacity 0.2s ease, transform 0.2s ease; | |
} | |
.eva-course.closing .eva-course-front { | |
opacity: 1; | |
transform: translateY(0); | |
transition: opacity 0.3s ease 0.1s, transform 0.3s ease 0.1s; | |
} | |
""" | |
def get_html_header(): | |
"""Generate the HTML header with CSS and meta tags.""" | |
return """<!DOCTYPE html> | |
<html lang="en" data-theme="light"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Marimo Learn - Interactive Educational Notebooks</title> | |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
<style> | |
{css} | |
</style> | |
</head> | |
<body> | |
<div class="eva-container"> | |
<header class="eva-header"> | |
<div class="eva-logo">MARIMO LEARN</div> | |
<nav class="eva-nav"> | |
<a href="#features">Features</a> | |
<a href="#courses">Courses</a> | |
<a href="#contribute">Contribute</a> | |
<a href="https://docs.marimo.io" target="_blank">Documentation</a> | |
<a href="https://github.com/marimo-team/learn" target="_blank">GitHub</a> | |
<button id="themeToggle" class="theme-toggle" aria-label="Toggle dark/light mode"> | |
<i class="fas fa-moon"></i> | |
</button> | |
</nav> | |
</header>""" | |
def get_html_hero_section(): | |
"""Generate the hero section of the page.""" | |
return """ | |
<section class="eva-hero"> | |
<h1>Interactive Learning with Marimo<span class="eva-cursor"></span></h1> | |
<p> | |
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. | |
</p> | |
<a href="#courses" class="eva-button">Explore Courses</a> | |
</section>""" | |
def get_html_features_section(): | |
"""Generate the features section of the page.""" | |
return """ | |
<section id="features"> | |
<h2 class="eva-section-title">Why Marimo Learn?</h2> | |
<div class="eva-features"> | |
<div class="eva-feature"> | |
<div class="eva-feature-icon"><i class="fas fa-bolt"></i></div> | |
<h3>Reactive Notebooks</h3> | |
<p>Experience the power of reactive programming with marimo notebooks that automatically update when dependencies change.</p> | |
</div> | |
<div class="eva-feature"> | |
<div class="eva-feature-icon"><i class="fas fa-code"></i></div> | |
<h3>Learn by Doing</h3> | |
<p>Interactive examples and exercises help you understand concepts through hands-on practice.</p> | |
</div> | |
<div class="eva-feature"> | |
<div class="eva-feature-icon"><i class="fas fa-graduation-cap"></i></div> | |
<h3>Comprehensive Courses</h3> | |
<p>From Python basics to advanced optimization techniques, our courses cover a wide range of topics.</p> | |
</div> | |
</div> | |
</section>""" | |
def get_html_courses_start(): | |
"""Generate the beginning of the courses section.""" | |
return """ | |
<section id="courses"> | |
<h2 class="eva-section-title">Explore Courses</h2> | |
<div class="eva-search"> | |
<input type="text" id="courseSearch" placeholder="Search courses and notebooks..."> | |
<span class="eva-search-icon"><i class="fas fa-search"></i></span> | |
</div> | |
<div class="eva-courses">""" | |
def generate_course_card(course, notebook_count, is_wip): | |
"""Generate HTML for a single course card.""" | |
html = f'<div class="eva-course" data-course-id="{course["id"]}">\n' | |
# Add WIP badge if needed | |
if is_wip: | |
html += ' <div class="eva-course-badge"><i class="fas fa-code-branch"></i> In Progress</div>\n' | |
html += f''' <div class="eva-course-header"> | |
<h2 class="eva-course-title">{course["title"]}</h2> | |
<span class="eva-course-toggle"><i class="fas fa-chevron-down"></i></span> | |
</div> | |
<div class="eva-course-front"> | |
<p class="eva-course-description">{course["description"]}</p> | |
<div class="eva-course-stats"> | |
<span><i class="fas fa-book"></i> {notebook_count} notebook{"s" if notebook_count != 1 else ""}</span> | |
</div> | |
<button class="eva-button eva-course-button">View Notebooks</button> | |
</div> | |
<div class="eva-course-content"> | |
<div class="eva-notebooks"> | |
''' | |
# Add notebooks | |
for i, notebook in enumerate(course["notebooks"]): | |
notebook_number = notebook.get("original_number", f"{i+1:02d}") | |
html += f''' <div class="eva-notebook"> | |
<span class="eva-notebook-number">{notebook_number}</span> | |
<a href="{notebook["path"].replace(".py", ".html")}" data-notebook-title="{notebook["display_name"]}">{notebook["display_name"]}</a> | |
</div> | |
''' | |
html += ''' </div> | |
</div> | |
</div> | |
''' | |
return html | |
def generate_course_cards(courses): | |
"""Generate HTML for all course cards.""" | |
html = "" | |
# Define the custom order for courses | |
course_order = ["python", "probability", "polars", "optimization", "functional_programming"] | |
# Create a dictionary of courses by ID for easy lookup | |
courses_by_id = {course["id"]: course for course in courses.values()} | |
# Determine which courses are "work in progress" based on description or notebook count | |
work_in_progress = set() | |
for course_id, course in courses_by_id.items(): | |
# Consider a course as "work in progress" if it has few notebooks or contains specific phrases | |
if (len(course["notebooks"]) < 5 or | |
"work in progress" in course["description"].lower() or | |
"help us add" in course["description"].lower() or | |
"check back later" in course["description"].lower()): | |
work_in_progress.add(course_id) | |
# First output courses in the specified order | |
for course_id in course_order: | |
if course_id in courses_by_id: | |
course = courses_by_id[course_id] | |
# Skip if no notebooks | |
if not course["notebooks"]: | |
continue | |
# Count notebooks | |
notebook_count = len(course["notebooks"]) | |
# Determine if this course is a work in progress | |
is_wip = course_id in work_in_progress | |
html += generate_course_card(course, notebook_count, is_wip) | |
# Remove from the dictionary so we don't output it again | |
del courses_by_id[course_id] | |
# Then output any remaining courses alphabetically | |
sorted_remaining_courses = sorted(courses_by_id.values(), key=lambda x: x["title"]) | |
for course in sorted_remaining_courses: | |
# Skip if no notebooks | |
if not course["notebooks"]: | |
continue | |
# Count notebooks | |
notebook_count = len(course["notebooks"]) | |
# Determine if this course is a work in progress | |
is_wip = course["id"] in work_in_progress | |
html += generate_course_card(course, notebook_count, is_wip) | |
return html | |
def get_html_courses_end(): | |
"""Generate the end of the courses section.""" | |
return """ </div> | |
</section>""" | |
def get_html_contribute_section(): | |
"""Generate the contribute section.""" | |
return """ | |
<section id="contribute" class="eva-cta"> | |
<h2>Contribute to Marimo Learn</h2> | |
<p> | |
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! | |
</p> | |
<a href="https://github.com/marimo-team/learn" target="_blank" class="eva-button"> | |
<i class="fab fa-github"></i> Contribute on GitHub | |
</a> | |
</section>""" | |
def get_html_footer(): | |
"""Generate the page footer.""" | |
return """ | |
<footer class="eva-footer"> | |
<div class="eva-footer-logo"> | |
<a href="https://marimo.io" target="_blank"> | |
<img src="https://marimo.io/logotype-wide.svg" alt="Marimo" width="200"> | |
</a> | |
</div> | |
<div class="eva-social-links"> | |
<a href="https://github.com/marimo-team" target="_blank" aria-label="GitHub"><i class="fab fa-github"></i></a> | |
<a href="https://marimo.io/discord?ref=learn" target="_blank" aria-label="Discord"><i class="fab fa-discord"></i></a> | |
<a href="https://twitter.com/marimo_io" target="_blank" aria-label="Twitter"><i class="fab fa-twitter"></i></a> | |
<a href="https://www.youtube.com/@marimo-team" target="_blank" aria-label="YouTube"><i class="fab fa-youtube"></i></a> | |
<a href="https://www.linkedin.com/company/marimo-io" target="_blank" aria-label="LinkedIn"><i class="fab fa-linkedin"></i></a> | |
</div> | |
<div class="eva-footer-links"> | |
<a href="https://marimo.io" target="_blank">Website</a> | |
<a href="https://docs.marimo.io" target="_blank">Documentation</a> | |
<a href="https://github.com/marimo-team/learn" target="_blank">GitHub</a> | |
</div> | |
<div class="eva-footer-copyright"> | |
© 2025 Marimo Inc. All rights reserved. | |
</div> | |
</footer>""" | |
def get_html_scripts(): | |
"""Generate the JavaScript for the page.""" | |
return """ | |
<script> | |
// Set light theme as default immediately | |
document.documentElement.setAttribute('data-theme', 'light'); | |
document.addEventListener('DOMContentLoaded', function() { | |
// Theme toggle functionality | |
const themeToggle = document.getElementById('themeToggle'); | |
const themeIcon = themeToggle.querySelector('i'); | |
// Update theme icon based on current theme | |
updateThemeIcon('light'); | |
// Check localStorage for saved theme preference | |
const savedTheme = localStorage.getItem('theme'); | |
if (savedTheme && savedTheme !== 'light') { | |
document.documentElement.setAttribute('data-theme', savedTheme); | |
updateThemeIcon(savedTheme); | |
} | |
// Toggle theme when button is clicked | |
themeToggle.addEventListener('click', () => { | |
const currentTheme = document.documentElement.getAttribute('data-theme'); | |
const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; | |
document.documentElement.setAttribute('data-theme', newTheme); | |
localStorage.setItem('theme', newTheme); | |
updateThemeIcon(newTheme); | |
}); | |
function updateThemeIcon(theme) { | |
if (theme === 'dark') { | |
themeIcon.className = 'fas fa-sun'; | |
} else { | |
themeIcon.className = 'fas fa-moon'; | |
} | |
} | |
// Terminal typing effect for hero text | |
const heroTitle = document.querySelector('.eva-hero h1'); | |
const heroText = document.querySelector('.eva-hero p'); | |
const cursor = document.querySelector('.eva-cursor'); | |
const originalTitle = heroTitle.textContent; | |
const originalText = heroText.textContent.trim(); | |
heroTitle.textContent = ''; | |
heroText.textContent = ''; | |
let titleIndex = 0; | |
let textIndex = 0; | |
function typeTitle() { | |
if (titleIndex < originalTitle.length) { | |
heroTitle.textContent += originalTitle.charAt(titleIndex); | |
titleIndex++; | |
setTimeout(typeTitle, 50); | |
} else { | |
cursor.style.display = 'none'; | |
setTimeout(typeText, 500); | |
} | |
} | |
function typeText() { | |
if (textIndex < originalText.length) { | |
heroText.textContent += originalText.charAt(textIndex); | |
textIndex++; | |
setTimeout(typeText, 20); | |
} | |
} | |
typeTitle(); | |
// Course toggle functionality - flashcard style | |
const courseHeaders = document.querySelectorAll('.eva-course-header'); | |
const courseButtons = document.querySelectorAll('.eva-course-button'); | |
// Function to toggle course | |
function toggleCourse(course) { | |
const isActive = course.classList.contains('active'); | |
// First close all courses with a slight delay for better visual effect | |
document.querySelectorAll('.eva-course.active').forEach(c => { | |
if (c !== course) { | |
// Add a closing class for animation | |
c.classList.add('closing'); | |
// Remove active class after a short delay | |
setTimeout(() => { | |
c.classList.remove('active'); | |
c.classList.remove('closing'); | |
}, 300); | |
} | |
}); | |
// Toggle the clicked course | |
if (!isActive) { | |
// Add a small delay before opening to allow others to close | |
setTimeout(() => { | |
course.classList.add('active'); | |
// Check if the course has any notebooks | |
const notebooks = course.querySelectorAll('.eva-notebook'); | |
const content = course.querySelector('.eva-course-content'); | |
if (notebooks.length === 0 && !content.querySelector('.eva-empty-message')) { | |
// If no notebooks, show a message | |
const emptyMessage = document.createElement('p'); | |
emptyMessage.className = 'eva-empty-message'; | |
emptyMessage.textContent = 'No notebooks available in this course yet.'; | |
emptyMessage.style.color = 'var(--eva-text)'; | |
emptyMessage.style.fontStyle = 'italic'; | |
emptyMessage.style.opacity = '0.7'; | |
emptyMessage.style.textAlign = 'center'; | |
emptyMessage.style.padding = '1rem 0'; | |
content.appendChild(emptyMessage); | |
} | |
// Animate notebooks to appear sequentially | |
notebooks.forEach((notebook, index) => { | |
notebook.style.opacity = '0'; | |
notebook.style.transform = 'translateX(-10px)'; | |
setTimeout(() => { | |
notebook.style.opacity = '1'; | |
notebook.style.transform = 'translateX(0)'; | |
}, 50 + (index * 30)); // Stagger the animations | |
}); | |
}, 100); | |
} | |
} | |
// Add click event to course headers | |
courseHeaders.forEach(header => { | |
header.addEventListener('click', function(e) { | |
e.preventDefault(); | |
e.stopPropagation(); | |
const currentCourse = this.closest('.eva-course'); | |
toggleCourse(currentCourse); | |
}); | |
}); | |
// Add click event to course buttons | |
courseButtons.forEach(button => { | |
button.addEventListener('click', function(e) { | |
e.preventDefault(); | |
e.stopPropagation(); | |
const currentCourse = this.closest('.eva-course'); | |
toggleCourse(currentCourse); | |
}); | |
}); | |
// Search functionality with improved matching | |
const searchInput = document.getElementById('courseSearch'); | |
const courses = document.querySelectorAll('.eva-course'); | |
const notebooks = document.querySelectorAll('.eva-notebook'); | |
searchInput.addEventListener('input', function() { | |
const searchTerm = this.value.toLowerCase(); | |
if (searchTerm === '') { | |
// Reset all visibility | |
courses.forEach(course => { | |
course.style.display = 'block'; | |
course.classList.remove('active'); | |
}); | |
notebooks.forEach(notebook => { | |
notebook.style.display = 'flex'; | |
}); | |
// Open the first course with notebooks by default when search is cleared | |
for (let i = 0; i < courses.length; i++) { | |
const courseNotebooks = courses[i].querySelectorAll('.eva-notebook'); | |
if (courseNotebooks.length > 0) { | |
courses[i].classList.add('active'); | |
break; | |
} | |
} | |
return; | |
} | |
// First hide all courses | |
courses.forEach(course => { | |
course.style.display = 'none'; | |
course.classList.remove('active'); | |
}); | |
// Then show courses and notebooks that match the search | |
let hasResults = false; | |
// Track which courses have matching notebooks | |
const coursesWithMatchingNotebooks = new Set(); | |
// First check notebooks | |
notebooks.forEach(notebook => { | |
const notebookTitle = notebook.querySelector('a').getAttribute('data-notebook-title').toLowerCase(); | |
const matchesSearch = notebookTitle.includes(searchTerm); | |
notebook.style.display = matchesSearch ? 'flex' : 'none'; | |
if (matchesSearch) { | |
const course = notebook.closest('.eva-course'); | |
coursesWithMatchingNotebooks.add(course.getAttribute('data-course-id')); | |
hasResults = true; | |
} | |
}); | |
// Then check course titles and descriptions | |
courses.forEach(course => { | |
const courseId = course.getAttribute('data-course-id'); | |
const courseTitle = course.querySelector('.eva-course-title').textContent.toLowerCase(); | |
const courseDescription = course.querySelector('.eva-course-description').textContent.toLowerCase(); | |
const courseMatches = courseTitle.includes(searchTerm) || courseDescription.includes(searchTerm); | |
// Show course if it matches or has matching notebooks | |
if (courseMatches || coursesWithMatchingNotebooks.has(courseId)) { | |
course.style.display = 'block'; | |
course.classList.add('active'); | |
hasResults = true; | |
// If course matches but doesn't have matching notebooks, show all its notebooks | |
if (courseMatches && !coursesWithMatchingNotebooks.has(courseId)) { | |
course.querySelectorAll('.eva-notebook').forEach(nb => { | |
nb.style.display = 'flex'; | |
}); | |
} | |
} | |
}); | |
}); | |
// Open the first course with notebooks by default | |
let firstCourseWithNotebooks = null; | |
for (let i = 0; i < courses.length; i++) { | |
const courseNotebooks = courses[i].querySelectorAll('.eva-notebook'); | |
if (courseNotebooks.length > 0) { | |
firstCourseWithNotebooks = courses[i]; | |
break; | |
} | |
} | |
if (firstCourseWithNotebooks) { | |
firstCourseWithNotebooks.classList.add('active'); | |
} else if (courses.length > 0) { | |
// If no courses have notebooks, just open the first one | |
courses[0].classList.add('active'); | |
} | |
// Smooth scrolling for anchor links | |
document.querySelectorAll('a[href^="#"]').forEach(anchor => { | |
anchor.addEventListener('click', function(e) { | |
e.preventDefault(); | |
const targetId = this.getAttribute('href'); | |
const targetElement = document.querySelector(targetId); | |
if (targetElement) { | |
window.scrollTo({ | |
top: targetElement.offsetTop - 100, | |
behavior: 'smooth' | |
}); | |
} | |
}); | |
}); | |
}); | |
</script>""" | |
def get_html_footer_closing(): | |
"""Generate closing HTML tags.""" | |
return """ | |
</div> | |
</body> | |
</html>""" | |
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: | |
# Build the page HTML from individual components | |
header = get_html_header().format(css=generate_eva_css()) | |
hero = get_html_hero_section() | |
features = get_html_features_section() | |
courses_start = get_html_courses_start() | |
course_cards = generate_course_cards(courses) | |
courses_end = get_html_courses_end() | |
contribute = get_html_contribute_section() | |
footer = get_html_footer() | |
scripts = get_html_scripts() | |
closing = get_html_footer_closing() | |
# Write all elements to the file | |
f.write(header) | |
f.write(hero) | |
f.write(features) | |
f.write(courses_start) | |
f.write(course_cards) | |
f.write(courses_end) | |
f.write(contribute) | |
f.write(footer) | |
f.write(scripts) | |
f.write(closing) | |
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() | |