mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-04-30 09:38:38 -05:00
feat: add newsletter system with Buttondown integration and CLI commands
Adds newsletter infrastructure: CLI commands (new, list, preview, publish, fetch, status) integrated into binder, Quarto archive site config for mlsysbook.ai/newsletter/, and 12-month editorial content plan. Drafts are gitignored for private local writing; sent newsletters are committed as the public archive. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -201,3 +201,7 @@ book/quarto/index.idx
|
|||||||
book/quarto/index.ilg
|
book/quarto/index.ilg
|
||||||
book/quarto/index.ind
|
book/quarto/index.ind
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Newsletter drafts (work-in-progress, not public until sent)
|
||||||
|
newsletter/drafts/*.md
|
||||||
|
!newsletter/drafts/_template.md
|
||||||
|
|||||||
425
book/cli/commands/newsletter.py
Normal file
425
book/cli/commands/newsletter.py
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
"""
|
||||||
|
Newsletter command implementation for MLSysBook CLI.
|
||||||
|
|
||||||
|
Manages newsletter drafts, publishing to Buttondown, and fetching
|
||||||
|
sent newsletters for the website archive.
|
||||||
|
|
||||||
|
Requires BUTTONDOWN_API_KEY environment variable for API operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.table import Table
|
||||||
|
from rich.panel import Panel
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
BUTTONDOWN_API_URL = "https://api.buttondown.com/v1/emails"
|
||||||
|
BUTTONDOWN_SUBSCRIBERS_URL = "https://api.buttondown.com/v1/subscribers"
|
||||||
|
|
||||||
|
|
||||||
|
class NewsletterCommand:
|
||||||
|
"""Handles newsletter operations for the MLSysBook."""
|
||||||
|
|
||||||
|
def __init__(self, config_manager, verbose: bool = False):
|
||||||
|
self.config_manager = config_manager
|
||||||
|
self.verbose = verbose
|
||||||
|
# Newsletter lives at repo root, not inside book/
|
||||||
|
self.newsletter_dir = config_manager.root_dir / "newsletter"
|
||||||
|
self.drafts_dir = self.newsletter_dir / "drafts"
|
||||||
|
self.sent_dir = self.newsletter_dir / "sent"
|
||||||
|
self.posts_dir = self.newsletter_dir / "posts"
|
||||||
|
self.template_path = self.drafts_dir / "_template.md"
|
||||||
|
|
||||||
|
def _get_api_key(self) -> Optional[str]:
|
||||||
|
"""Get Buttondown API key from environment."""
|
||||||
|
key = os.environ.get("BUTTONDOWN_API_KEY")
|
||||||
|
if not key:
|
||||||
|
console.print("[red]BUTTONDOWN_API_KEY environment variable not set.[/red]")
|
||||||
|
console.print("[dim]Set it with: export BUTTONDOWN_API_KEY=your-key-here[/dim]")
|
||||||
|
console.print("[dim]Get your key at: https://buttondown.com/settings/api[/dim]")
|
||||||
|
return key
|
||||||
|
|
||||||
|
def _slugify(self, title: str) -> str:
|
||||||
|
"""Convert a title to a filename-safe slug."""
|
||||||
|
slug = title.lower().strip()
|
||||||
|
slug = re.sub(r'[^\w\s-]', '', slug)
|
||||||
|
slug = re.sub(r'[\s_]+', '-', slug)
|
||||||
|
slug = re.sub(r'-+', '-', slug)
|
||||||
|
return slug.strip('-')
|
||||||
|
|
||||||
|
def _parse_frontmatter(self, path: Path) -> dict:
|
||||||
|
"""Parse YAML front matter from a markdown file."""
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
if not text.startswith("---"):
|
||||||
|
return {}
|
||||||
|
parts = text.split("---", 2)
|
||||||
|
if len(parts) < 3:
|
||||||
|
return {}
|
||||||
|
frontmatter = {}
|
||||||
|
for line in parts[1].strip().splitlines():
|
||||||
|
if ":" in line:
|
||||||
|
key, _, value = line.partition(":")
|
||||||
|
value = value.strip().strip('"').strip("'")
|
||||||
|
frontmatter[key.strip()] = value
|
||||||
|
return frontmatter
|
||||||
|
|
||||||
|
def _get_body(self, path: Path) -> str:
|
||||||
|
"""Extract markdown body (everything after front matter)."""
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
if not text.startswith("---"):
|
||||||
|
return text
|
||||||
|
parts = text.split("---", 2)
|
||||||
|
if len(parts) < 3:
|
||||||
|
return text
|
||||||
|
return parts[2].strip()
|
||||||
|
|
||||||
|
def run(self, args):
|
||||||
|
"""Route newsletter subcommands."""
|
||||||
|
if not args or args[0] in ("-h", "--help"):
|
||||||
|
self._show_help()
|
||||||
|
return True
|
||||||
|
|
||||||
|
subcommand = args[0].lower()
|
||||||
|
sub_args = args[1:]
|
||||||
|
|
||||||
|
subcommands = {
|
||||||
|
"new": self._handle_new,
|
||||||
|
"list": self._handle_list,
|
||||||
|
"preview": self._handle_preview,
|
||||||
|
"publish": self._handle_publish,
|
||||||
|
"fetch": self._handle_fetch,
|
||||||
|
"status": self._handle_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
if subcommand in subcommands:
|
||||||
|
return subcommands[subcommand](sub_args)
|
||||||
|
else:
|
||||||
|
console.print(f"[red]Unknown newsletter subcommand: {subcommand}[/red]")
|
||||||
|
self._show_help()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _show_help(self):
|
||||||
|
"""Display newsletter command help."""
|
||||||
|
help_table = Table(title="Newsletter Commands", show_header=True)
|
||||||
|
help_table.add_column("Command", style="cyan", width=36)
|
||||||
|
help_table.add_column("Description", style="dim")
|
||||||
|
help_table.add_row("newsletter new <title>", "Create a new draft from template")
|
||||||
|
help_table.add_row("newsletter list", "List drafts and their status")
|
||||||
|
help_table.add_row("newsletter preview <slug>", "Open a draft for preview")
|
||||||
|
help_table.add_row("newsletter publish <slug>", "Push draft to Buttondown as draft email")
|
||||||
|
help_table.add_row("newsletter fetch", "Pull sent newsletters for website archive")
|
||||||
|
help_table.add_row("newsletter status", "Show subscriber count and recent sends")
|
||||||
|
console.print(help_table)
|
||||||
|
|
||||||
|
# ── new ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _handle_new(self, args) -> bool:
|
||||||
|
"""Create a new newsletter draft from the template."""
|
||||||
|
if not args:
|
||||||
|
console.print("[red]Usage: ./binder newsletter new \"Your Newsletter Title\"[/red]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
title = " ".join(args)
|
||||||
|
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
slug = self._slugify(title)
|
||||||
|
filename = f"{date_str}_{slug}.md"
|
||||||
|
dest = self.drafts_dir / filename
|
||||||
|
|
||||||
|
if dest.exists():
|
||||||
|
console.print(f"[yellow]Draft already exists: {dest.name}[/yellow]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Read template and populate
|
||||||
|
if self.template_path.exists():
|
||||||
|
template = self.template_path.read_text(encoding="utf-8")
|
||||||
|
else:
|
||||||
|
template = "---\ntitle: \"\"\ndate: \"\"\ndraft: true\n---\n\nWrite here.\n"
|
||||||
|
|
||||||
|
content = template.replace("Newsletter Title Here", title)
|
||||||
|
content = content.replace("YYYY-MM-DD", date_str)
|
||||||
|
|
||||||
|
dest.write_text(content, encoding="utf-8")
|
||||||
|
console.print(f"[green]Created draft: newsletter/drafts/{filename}[/green]")
|
||||||
|
console.print(f"[dim]Edit it, then publish with: ./binder newsletter publish {slug}[/dim]")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ── list ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _handle_list(self, args) -> bool:
|
||||||
|
"""List all newsletter drafts and sent newsletters."""
|
||||||
|
table = Table(title="Newsletters", show_header=True)
|
||||||
|
table.add_column("Status", style="bold", width=8)
|
||||||
|
table.add_column("Date", width=12)
|
||||||
|
table.add_column("Title", min_width=30)
|
||||||
|
table.add_column("File", style="dim")
|
||||||
|
|
||||||
|
# Drafts
|
||||||
|
draft_files = sorted(self.drafts_dir.glob("*.md"))
|
||||||
|
for f in draft_files:
|
||||||
|
if f.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
fm = self._parse_frontmatter(f)
|
||||||
|
title = fm.get("title", f.stem)
|
||||||
|
date = fm.get("date", "")
|
||||||
|
is_draft = fm.get("draft", "true").lower() == "true"
|
||||||
|
status = "[yellow]draft[/yellow]" if is_draft else "[blue]ready[/blue]"
|
||||||
|
table.add_row(status, date, title, f"drafts/{f.name}")
|
||||||
|
|
||||||
|
# Sent
|
||||||
|
sent_files = sorted(self.sent_dir.glob("*.md"))
|
||||||
|
for f in sent_files:
|
||||||
|
fm = self._parse_frontmatter(f)
|
||||||
|
title = fm.get("title", f.stem)
|
||||||
|
date = fm.get("date", "")
|
||||||
|
table.add_row("[green]sent[/green]", date, title, f"sent/{f.name}")
|
||||||
|
|
||||||
|
if table.row_count == 0:
|
||||||
|
console.print("[dim]No newsletters found. Create one with: ./binder newsletter new \"Title\"[/dim]")
|
||||||
|
else:
|
||||||
|
console.print(table)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ── preview ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _handle_preview(self, args) -> bool:
|
||||||
|
"""Find and display a draft for preview."""
|
||||||
|
if not args:
|
||||||
|
console.print("[red]Usage: ./binder newsletter preview <slug>[/red]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
slug = args[0]
|
||||||
|
draft = self._find_draft(slug)
|
||||||
|
if not draft:
|
||||||
|
console.print(f"[red]No draft matching '{slug}' found in newsletter/drafts/[/red]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
fm = self._parse_frontmatter(draft)
|
||||||
|
body = self._get_body(draft)
|
||||||
|
|
||||||
|
panel = Panel(
|
||||||
|
body[:2000] + ("\n..." if len(body) > 2000 else ""),
|
||||||
|
title=fm.get("title", draft.stem),
|
||||||
|
subtitle=fm.get("date", ""),
|
||||||
|
border_style="cyan",
|
||||||
|
)
|
||||||
|
console.print(panel)
|
||||||
|
console.print(f"[dim]Full file: {draft}[/dim]")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ── publish ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _handle_publish(self, args) -> bool:
|
||||||
|
"""Push a draft to Buttondown as a draft email (not sent)."""
|
||||||
|
if not args:
|
||||||
|
console.print("[red]Usage: ./binder newsletter publish <slug>[/red]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
api_key = self._get_api_key()
|
||||||
|
if not api_key:
|
||||||
|
return False
|
||||||
|
|
||||||
|
slug = args[0]
|
||||||
|
draft = self._find_draft(slug)
|
||||||
|
if not draft:
|
||||||
|
console.print(f"[red]No draft matching '{slug}' found in newsletter/drafts/[/red]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
fm = self._parse_frontmatter(draft)
|
||||||
|
title = fm.get("title", draft.stem)
|
||||||
|
body = self._get_body(draft)
|
||||||
|
|
||||||
|
console.print(f"[cyan]Publishing draft to Buttondown: \"{title}\"[/cyan]")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
payload = json.dumps({
|
||||||
|
"subject": title,
|
||||||
|
"body": body,
|
||||||
|
"status": "draft",
|
||||||
|
}).encode("utf-8")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BUTTONDOWN_API_URL,
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Token {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
result = json.loads(resp.read().decode("utf-8"))
|
||||||
|
email_id = result.get("id", "unknown")
|
||||||
|
console.print(f"[green]Draft created in Buttondown (ID: {email_id})[/green]")
|
||||||
|
console.print("[dim]Your wife can now review and send it from the Buttondown dashboard:[/dim]")
|
||||||
|
console.print("[dim]https://buttondown.com/mlsysbook/emails[/dim]")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
error_body = e.read().decode("utf-8") if e.fp else ""
|
||||||
|
console.print(f"[red]Buttondown API error ({e.code}): {error_body}[/red]")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]Error publishing: {e}[/red]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── fetch ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _handle_fetch(self, args) -> bool:
|
||||||
|
"""Fetch sent newsletters from Buttondown for the website archive."""
|
||||||
|
api_key = self._get_api_key()
|
||||||
|
if not api_key:
|
||||||
|
return False
|
||||||
|
|
||||||
|
console.print("[cyan]Fetching sent newsletters from Buttondown...[/cyan]")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
all_emails = []
|
||||||
|
url = f"{BUTTONDOWN_API_URL}?status=sent&ordering=-publish_date"
|
||||||
|
|
||||||
|
while url:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
headers={"Authorization": f"Token {api_key}"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
results = data.get("results", data if isinstance(data, list) else [])
|
||||||
|
all_emails.extend(results)
|
||||||
|
url = data.get("next") if isinstance(data, dict) else None
|
||||||
|
|
||||||
|
if not all_emails:
|
||||||
|
console.print("[yellow]No sent newsletters found on Buttondown.[/yellow]")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Write each email as a markdown file in sent/ and posts/
|
||||||
|
count = 0
|
||||||
|
for email in all_emails:
|
||||||
|
subject = email.get("subject", "Untitled")
|
||||||
|
body = email.get("body", "")
|
||||||
|
publish_date = email.get("publish_date", "")
|
||||||
|
email_id = email.get("id", "unknown")
|
||||||
|
|
||||||
|
# Parse date
|
||||||
|
if publish_date:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(publish_date.replace("Z", "+00:00"))
|
||||||
|
date_str = dt.strftime("%Y-%m-%d")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
date_str = publish_date[:10] if len(publish_date) >= 10 else "unknown"
|
||||||
|
else:
|
||||||
|
date_str = "unknown"
|
||||||
|
|
||||||
|
slug = self._slugify(subject)
|
||||||
|
filename = f"{date_str}_{slug}.md"
|
||||||
|
|
||||||
|
# Build front matter
|
||||||
|
frontmatter = (
|
||||||
|
f"---\n"
|
||||||
|
f"title: \"{subject}\"\n"
|
||||||
|
f"date: \"{date_str}\"\n"
|
||||||
|
f"author: \"Vijay Janapa Reddi\"\n"
|
||||||
|
f"description: \"{subject}\"\n"
|
||||||
|
f"categories: [newsletter]\n"
|
||||||
|
f"buttondown-id: \"{email_id}\"\n"
|
||||||
|
f"---\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
content = frontmatter + body
|
||||||
|
|
||||||
|
# Write to both sent/ (committed) and posts/ (for Quarto listing)
|
||||||
|
(self.sent_dir / filename).write_text(content, encoding="utf-8")
|
||||||
|
(self.posts_dir / filename).write_text(content, encoding="utf-8")
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
console.print(f"[green]Fetched {count} newsletter(s) to newsletter/sent/ and newsletter/posts/[/green]")
|
||||||
|
console.print("[dim]Commit newsletter/sent/ to version-control the archive.[/dim]")
|
||||||
|
console.print("[dim]newsletter/posts/ is gitignored (generated at build time).[/dim]")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
error_body = e.read().decode("utf-8") if e.fp else ""
|
||||||
|
console.print(f"[red]Buttondown API error ({e.code}): {error_body}[/red]")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]Error fetching newsletters: {e}[/red]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── status ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _handle_status(self, args) -> bool:
|
||||||
|
"""Show newsletter status: subscriber count, recent sends."""
|
||||||
|
api_key = self._get_api_key()
|
||||||
|
if not api_key:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
# Get subscriber count
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{BUTTONDOWN_SUBSCRIBERS_URL}?page_size=1",
|
||||||
|
headers={"Authorization": f"Token {api_key}"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
subscriber_count = data.get("count", "?")
|
||||||
|
|
||||||
|
# Get recent emails
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{BUTTONDOWN_API_URL}?status=sent&ordering=-publish_date&page_size=5",
|
||||||
|
headers={"Authorization": f"Token {api_key}"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
recent = data.get("results", data if isinstance(data, list) else [])
|
||||||
|
|
||||||
|
# Display
|
||||||
|
console.print(Panel(
|
||||||
|
f"[bold]{subscriber_count}[/bold] subscribers",
|
||||||
|
title="Buttondown Status",
|
||||||
|
border_style="green",
|
||||||
|
))
|
||||||
|
|
||||||
|
if recent:
|
||||||
|
table = Table(title="Recent Newsletters", show_header=True)
|
||||||
|
table.add_column("Date", width=12)
|
||||||
|
table.add_column("Subject", min_width=30)
|
||||||
|
for email in recent[:5]:
|
||||||
|
subject = email.get("subject", "Untitled")
|
||||||
|
pub = email.get("publish_date", "")
|
||||||
|
date_str = pub[:10] if pub else "?"
|
||||||
|
table.add_row(date_str, subject)
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]Error fetching status: {e}[/red]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _find_draft(self, slug: str) -> Optional[Path]:
|
||||||
|
"""Find a draft file matching the given slug (partial match)."""
|
||||||
|
for f in sorted(self.drafts_dir.glob("*.md")):
|
||||||
|
if f.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
if slug in f.stem:
|
||||||
|
return f
|
||||||
|
return None
|
||||||
@@ -30,6 +30,7 @@ from cli.commands.formatting import FormatCommand
|
|||||||
from cli.commands.info import InfoCommand
|
from cli.commands.info import InfoCommand
|
||||||
from cli.commands.bib import BibCommand
|
from cli.commands.bib import BibCommand
|
||||||
from cli.commands.render import RenderCommand
|
from cli.commands.render import RenderCommand
|
||||||
|
from cli.commands.newsletter import NewsletterCommand
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ class MLSysBookCLI:
|
|||||||
self.info_command = InfoCommand(self.config_manager, self.chapter_discovery)
|
self.info_command = InfoCommand(self.config_manager, self.chapter_discovery)
|
||||||
self.bib_command = BibCommand(self.config_manager, self.chapter_discovery)
|
self.bib_command = BibCommand(self.config_manager, self.chapter_discovery)
|
||||||
self.render_command = RenderCommand(self.config_manager, self.chapter_discovery)
|
self.render_command = RenderCommand(self.config_manager, self.chapter_discovery)
|
||||||
|
self.newsletter_command = NewsletterCommand(self.config_manager, verbose=verbose)
|
||||||
|
|
||||||
def show_banner(self):
|
def show_banner(self):
|
||||||
"""Display the CLI banner."""
|
"""Display the CLI banner."""
|
||||||
@@ -133,6 +135,19 @@ class MLSysBookCLI:
|
|||||||
quality_table.add_row("bib list|clean|update|sync", "Bibliography management", "./binder bib sync --vol1")
|
quality_table.add_row("bib list|clean|update|sync", "Bibliography management", "./binder bib sync --vol1")
|
||||||
quality_table.add_row("render plots [--vol1|chapter]", "Render matplotlib plots to PNG gallery", "./binder render plots --vol1")
|
quality_table.add_row("render plots [--vol1|chapter]", "Render matplotlib plots to PNG gallery", "./binder render plots --vol1")
|
||||||
|
|
||||||
|
# Newsletter Commands
|
||||||
|
nl_table = Table(show_header=True, header_style="bold magenta", box=None)
|
||||||
|
nl_table.add_column("Command", style="magenta", width=38)
|
||||||
|
nl_table.add_column("Description", style="white", width=30)
|
||||||
|
nl_table.add_column("Example", style="dim", width=28)
|
||||||
|
|
||||||
|
nl_table.add_row("newsletter new <title>", "Create a new draft", './binder newsletter new "Vol2 Update"')
|
||||||
|
nl_table.add_row("newsletter list", "List drafts and sent", "./binder newsletter list")
|
||||||
|
nl_table.add_row("newsletter preview <slug>", "Preview a draft", "./binder newsletter preview vol2")
|
||||||
|
nl_table.add_row("newsletter publish <slug>", "Push draft to Buttondown", "./binder newsletter publish vol2")
|
||||||
|
nl_table.add_row("newsletter fetch", "Pull sent emails for website", "./binder newsletter fetch")
|
||||||
|
nl_table.add_row("newsletter status", "Subscriber count & recent", "./binder newsletter status")
|
||||||
|
|
||||||
# Management Commands
|
# Management Commands
|
||||||
mgmt_table = Table(show_header=True, header_style="bold blue", box=None)
|
mgmt_table = Table(show_header=True, header_style="bold blue", box=None)
|
||||||
mgmt_table.add_column("Command", style="green", width=38)
|
mgmt_table.add_column("Command", style="green", width=38)
|
||||||
@@ -153,6 +168,7 @@ class MLSysBookCLI:
|
|||||||
console.print(Panel(vol_table, title="📖 Volume Commands", border_style="magenta"))
|
console.print(Panel(vol_table, title="📖 Volume Commands", border_style="magenta"))
|
||||||
console.print(Panel(full_table, title="📚 Full Book Commands", border_style="blue"))
|
console.print(Panel(full_table, title="📚 Full Book Commands", border_style="blue"))
|
||||||
console.print(Panel(quality_table, title="✅ Quality & Formatting", border_style="yellow"))
|
console.print(Panel(quality_table, title="✅ Quality & Formatting", border_style="yellow"))
|
||||||
|
console.print(Panel(nl_table, title="📬 Newsletter", border_style="magenta"))
|
||||||
console.print(Panel(mgmt_table, title="🔧 Management", border_style="cyan"))
|
console.print(Panel(mgmt_table, title="🔧 Management", border_style="cyan"))
|
||||||
|
|
||||||
# Pro Tips
|
# Pro Tips
|
||||||
@@ -368,6 +384,10 @@ class MLSysBookCLI:
|
|||||||
"""Handle render command group (plots)."""
|
"""Handle render command group (plots)."""
|
||||||
return self.render_command.run(args)
|
return self.render_command.run(args)
|
||||||
|
|
||||||
|
def handle_newsletter_command(self, args):
|
||||||
|
"""Handle newsletter command group (new, list, preview, publish, fetch, status)."""
|
||||||
|
return self.newsletter_command.run(args)
|
||||||
|
|
||||||
|
|
||||||
def handle_debug_command(self, args):
|
def handle_debug_command(self, args):
|
||||||
"""Handle debug command.
|
"""Handle debug command.
|
||||||
@@ -463,6 +483,7 @@ class MLSysBookCLI:
|
|||||||
"info": self.handle_info_command,
|
"info": self.handle_info_command,
|
||||||
"bib": self.handle_bib_command,
|
"bib": self.handle_bib_command,
|
||||||
"render": self.handle_render_command,
|
"render": self.handle_render_command,
|
||||||
|
"newsletter": self.handle_newsletter_command,
|
||||||
"setup": self.handle_setup_command,
|
"setup": self.handle_setup_command,
|
||||||
"hello": self.handle_hello_command,
|
"hello": self.handle_hello_command,
|
||||||
"about": self.handle_about_command,
|
"about": self.handle_about_command,
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ website:
|
|||||||
- icon: lightbulb
|
- icon: lightbulb
|
||||||
text: "Labs (Coming 2026)"
|
text: "Labs (Coming 2026)"
|
||||||
href: labs/
|
href: labs/
|
||||||
|
- text: "---"
|
||||||
|
- icon: envelope
|
||||||
|
text: "Newsletter"
|
||||||
|
href: newsletter/
|
||||||
right:
|
right:
|
||||||
- icon: download
|
- icon: download
|
||||||
text: "Downloads"
|
text: "Downloads"
|
||||||
|
|||||||
65
newsletter/CONTENT_PLAN.md
Normal file
65
newsletter/CONTENT_PLAN.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Newsletter Content Plan — 2026
|
||||||
|
|
||||||
|
## The Thesis
|
||||||
|
|
||||||
|
**AI Engineering is the emerging discipline.**
|
||||||
|
|
||||||
|
Every issue reinforces a single coherent message: AI is not magic — it is
|
||||||
|
infrastructure, and infrastructure has laws. The newsletter charts the
|
||||||
|
formation of this field from the perspective of someone building its
|
||||||
|
curriculum, tools, and community.
|
||||||
|
|
||||||
|
## The Three Layers
|
||||||
|
|
||||||
|
Every newsletter contains three layers:
|
||||||
|
|
||||||
|
1. **A Big Idea** — A perspective on AI engineering (500-700 words)
|
||||||
|
2. **A Concrete Example** — A system, tool, benchmark, or research trend
|
||||||
|
3. **A Community Signal** — What the community is building (TinyTorch,
|
||||||
|
workshops, contributors, hardware kits, course adoptions)
|
||||||
|
|
||||||
|
## The 12-Issue Arc
|
||||||
|
|
||||||
|
| Month | Theme | Big Idea |
|
||||||
|
|-------|-------|----------|
|
||||||
|
| Jan | The Rise of AI Engineering | Define the shift: AI is systems, infrastructure, deployment, and physical interaction — not just models and papers |
|
||||||
|
| Feb | Why AI Systems Matter | Models alone are not enough: deployment, latency, memory, edge constraints, system co-design |
|
||||||
|
| Mar | The Stack of AI Engineering | Map the stack: models, training infrastructure, inference systems, edge hardware, applications |
|
||||||
|
| Apr | The Edge AI Moment | TinyML, embedded AI, physical AI — connect to TinyML4D and hardware kits |
|
||||||
|
| May | What an AI Engineer Actually Does | Data pipelines, evaluation, deployment, optimization, hardware co-design |
|
||||||
|
| Jun | The Tools of AI Engineering | PyTorch, ExecuTorch, vLLM, Ray, Triton, TinyTorch — why tools matter |
|
||||||
|
| Jul | Benchmarking AI Systems | MLPerf, evaluation, reproducibility — our territory |
|
||||||
|
| Aug | Physical AI | Robotics, edge devices, sensors, on-device inference |
|
||||||
|
| Sep | Teaching AI Engineering | What universities are missing, how courses are evolving, the MLSysBook and TinyTorch |
|
||||||
|
| Oct | AI Engineering in Industry | Case studies: Tesla, NVIDIA, OpenAI, Meta, Qualcomm |
|
||||||
|
| Nov | The Global AI Engineering Movement | Workshops, universities, contributors, global adoption |
|
||||||
|
| Dec | The Future of AI Engineering | State of the field, reflections, what surprised us, where we're going |
|
||||||
|
|
||||||
|
## Template Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Opening Essay (500-700 words)
|
||||||
|
A clear opinion or insight about AI engineering.
|
||||||
|
|
||||||
|
2. System Spotlight
|
||||||
|
A real system, tool, or paper examined through the lens of constraints.
|
||||||
|
|
||||||
|
3. What the Community Is Building
|
||||||
|
TinyTorch updates, workshop recaps, contributor highlights.
|
||||||
|
|
||||||
|
4. One Question for the Community
|
||||||
|
Invite responses and build engagement.
|
||||||
|
```
|
||||||
|
|
||||||
|
## The Competitive Edge
|
||||||
|
|
||||||
|
Three things this newsletter does that others don't:
|
||||||
|
|
||||||
|
1. **Build a field** — Not just AI adoption talk; defining AI Engineering
|
||||||
|
as a discipline with principles, curriculum, and community.
|
||||||
|
|
||||||
|
2. **Connect research, industry, and education** — Ecosystem view spanning
|
||||||
|
labs, open source, industry systems, and university courses.
|
||||||
|
|
||||||
|
3. **Show what is being built** — TinyTorch, MLSysBook, hardware kits,
|
||||||
|
benchmarks. Readers watch something grow, not just read about trends.
|
||||||
100
newsletter/_quarto.yml
Normal file
100
newsletter/_quarto.yml
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# NEWSLETTER ARCHIVE (mlsysbook.ai/newsletter/)
|
||||||
|
# =============================================================================
|
||||||
|
# Renders sent newsletters as a browsable listing page.
|
||||||
|
# Build from book/quarto:
|
||||||
|
# quarto render ../../newsletter/index.qmd
|
||||||
|
# Deploy _build/html-newsletter/ to https://mlsysbook.ai/newsletter/
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
project:
|
||||||
|
type: website
|
||||||
|
output-dir: ../book/quarto/_build/html-newsletter
|
||||||
|
resources: ..
|
||||||
|
|
||||||
|
website:
|
||||||
|
title: "Newsletter - Machine Learning Systems"
|
||||||
|
description: "Updates, insights, and progress from the Machine Learning Systems textbook project."
|
||||||
|
site-url: https://mlsysbook.ai/newsletter/
|
||||||
|
|
||||||
|
page-navigation: false
|
||||||
|
back-to-top-navigation: true
|
||||||
|
|
||||||
|
navbar:
|
||||||
|
background: light
|
||||||
|
logo: https://mlsysbook.ai/vol1/assets/images/icons/favicon.png
|
||||||
|
pinned: true
|
||||||
|
collapse: true
|
||||||
|
collapse-below: "lg"
|
||||||
|
title: "Machine Learning Systems"
|
||||||
|
left:
|
||||||
|
- text: "Textbook"
|
||||||
|
menu:
|
||||||
|
- icon: house
|
||||||
|
text: "Home"
|
||||||
|
href: https://mlsysbook.ai/
|
||||||
|
- text: "---"
|
||||||
|
- icon: journal
|
||||||
|
text: "Volume I: Foundations"
|
||||||
|
href: https://mlsysbook.ai/vol1/
|
||||||
|
- icon: journal
|
||||||
|
text: "Volume II: At Scale"
|
||||||
|
href: https://mlsysbook.ai/vol2/
|
||||||
|
- text: "---"
|
||||||
|
- icon: fire
|
||||||
|
text: "TinyTorch"
|
||||||
|
href: https://mlsysbook.ai/tinytorch/
|
||||||
|
- icon: cpu
|
||||||
|
text: "Hardware Kits"
|
||||||
|
href: https://mlsysbook.ai/kits/
|
||||||
|
- text: "---"
|
||||||
|
- icon: lightbulb
|
||||||
|
text: "Labs (Coming 2026)"
|
||||||
|
href: https://mlsysbook.ai/labs/
|
||||||
|
- text: "Newsletter"
|
||||||
|
href: ./
|
||||||
|
right:
|
||||||
|
- icon: envelope
|
||||||
|
text: "Subscribe"
|
||||||
|
href: https://mlsysbook.ai/vol1/#subscribe
|
||||||
|
id: "navbar-subscribe-btn"
|
||||||
|
- icon: github
|
||||||
|
text: "GitHub"
|
||||||
|
href: https://github.com/harvard-edge/cs249r_book
|
||||||
|
target: _blank
|
||||||
|
|
||||||
|
sidebar: []
|
||||||
|
|
||||||
|
page-footer:
|
||||||
|
left: |
|
||||||
|
© 2024-2026 Harvard University. Licensed under <a href="https://creativecommons.org/licenses/by-nc-sa/4.0/">CC-BY-NC-SA 4.0</a>
|
||||||
|
center: |
|
||||||
|
<a href="https://mlsysbook.ai/vol1/">Volume I</a> · <a href="https://mlsysbook.ai/vol2/">Volume II</a> · <a href="https://mlsysbook.ai/newsletter/">Newsletter</a>
|
||||||
|
right:
|
||||||
|
- icon: github
|
||||||
|
href: https://github.com/harvard-edge/cs249r_book
|
||||||
|
aria-label: "View source on GitHub"
|
||||||
|
background: light
|
||||||
|
border: true
|
||||||
|
|
||||||
|
format:
|
||||||
|
html:
|
||||||
|
toc: false
|
||||||
|
theme:
|
||||||
|
light:
|
||||||
|
- default
|
||||||
|
- ../book/quarto/assets/styles/style-vol1.scss
|
||||||
|
dark:
|
||||||
|
- default
|
||||||
|
- ../book/quarto/assets/styles/style-vol1.scss
|
||||||
|
- ../book/quarto/assets/styles/dark-mode-vol1.scss
|
||||||
|
respect-user-color-scheme: true
|
||||||
|
css: newsletter.css
|
||||||
|
include-in-header:
|
||||||
|
text: |
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<meta name="theme-color" content="#A51C30">
|
||||||
|
<link rel="icon" href="https://mlsysbook.ai/vol1/assets/images/icons/favicon.png" type="image/png">
|
||||||
31
newsletter/drafts/_template.md
Normal file
31
newsletter/drafts/_template.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
title: "Newsletter Title Here"
|
||||||
|
subtitle: "Optional subtitle"
|
||||||
|
date: "YYYY-MM-DD"
|
||||||
|
author: "Vijay Janapa Reddi"
|
||||||
|
categories: [update]
|
||||||
|
description: "Brief description for the archive listing page."
|
||||||
|
draft: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Write your newsletter content here in Markdown.
|
||||||
|
|
||||||
|
## Section Heading
|
||||||
|
|
||||||
|
Body text goes here. You can use all standard Markdown:
|
||||||
|
|
||||||
|
- Bullet points
|
||||||
|
- **Bold** and *italic*
|
||||||
|
- [Links](https://mlsysbook.ai)
|
||||||
|
|
||||||
|
## What's New
|
||||||
|
|
||||||
|
Highlight recent updates to the textbook, TinyTorch, hardware kits, or labs.
|
||||||
|
|
||||||
|
## Coming Up
|
||||||
|
|
||||||
|
Tease what's next for the ML Systems curriculum.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Thanks for reading! Share this with anyone interested in ML systems.*
|
||||||
40
newsletter/index.qmd
Normal file
40
newsletter/index.qmd
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
title: "Newsletter"
|
||||||
|
listing:
|
||||||
|
- id: newsletter-listing
|
||||||
|
contents:
|
||||||
|
- "sent/*.md"
|
||||||
|
- "posts/*.md"
|
||||||
|
sort: "date desc"
|
||||||
|
type: default
|
||||||
|
categories: true
|
||||||
|
date-format: "MMMM D, YYYY"
|
||||||
|
feed: true
|
||||||
|
fields: [title, date, description, categories]
|
||||||
|
format:
|
||||||
|
html:
|
||||||
|
toc: false
|
||||||
|
page-layout: full
|
||||||
|
title-block: none
|
||||||
|
---
|
||||||
|
|
||||||
|
::: {.content-visible when-format="html"}
|
||||||
|
|
||||||
|
```{=html}
|
||||||
|
<div class="newsletter-hero">
|
||||||
|
<div class="newsletter-hero-content">
|
||||||
|
<p class="newsletter-eyebrow">NEWSLETTER</p>
|
||||||
|
<h1 class="newsletter-title">From the<br/>Author's Desk.</h1>
|
||||||
|
<p class="newsletter-tagline">Updates, insights, and progress on the<br/>Machine Learning Systems curriculum.</p>
|
||||||
|
<p class="newsletter-cta">
|
||||||
|
<a href="https://buttondown.com/mlsysbook" target="_blank" rel="noopener" class="newsletter-subscribe-btn">Subscribe via Email</a>
|
||||||
|
<a href="https://buttondown.com/mlsysbook/rss" target="_blank" rel="noopener" class="newsletter-rss-btn">RSS Feed</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
::: {#newsletter-listing}
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::
|
||||||
160
newsletter/newsletter.css
Normal file
160
newsletter/newsletter.css
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
Newsletter Archive Styles — Machine Learning Systems
|
||||||
|
Matches the landing site design language (Inter font, Harvard crimson accent).
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--nl-accent: #A51C30;
|
||||||
|
--nl-accent-hover: #8a1728;
|
||||||
|
--nl-text: #1a1a2e;
|
||||||
|
--nl-text-muted: #6b7280;
|
||||||
|
--nl-bg: #ffffff;
|
||||||
|
--nl-card-bg: #f9fafb;
|
||||||
|
--nl-border: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode overrides */
|
||||||
|
.quarto-dark {
|
||||||
|
--nl-text: #e5e7eb;
|
||||||
|
--nl-text-muted: #9ca3af;
|
||||||
|
--nl-bg: #0f1117;
|
||||||
|
--nl-card-bg: #1a1d2e;
|
||||||
|
--nl-border: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero Section ── */
|
||||||
|
.newsletter-hero {
|
||||||
|
padding: 6rem 2rem 4rem;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-hero-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-eyebrow {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--nl-accent);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-title {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--nl-text);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-tagline {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--nl-text-muted);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-cta {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-subscribe-btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
background: var(--nl-accent);
|
||||||
|
color: #fff !important;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-subscribe-btn:hover {
|
||||||
|
background: var(--nl-accent-hover);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-rss-btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nl-text) !important;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1.5px solid var(--nl-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-rss-btn:hover {
|
||||||
|
border-color: var(--nl-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Listing Overrides ── */
|
||||||
|
#newsletter-listing {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto 4rem;
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#newsletter-listing .quarto-listing-default .list-group-item {
|
||||||
|
border: 1px solid var(--nl-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--nl-card-bg);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#newsletter-listing .quarto-listing-default .list-group-item:hover {
|
||||||
|
border-color: var(--nl-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#newsletter-listing .listing-title {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--nl-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#newsletter-listing .listing-date {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--nl-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#newsletter-listing .listing-description {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--nl-text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.newsletter-hero {
|
||||||
|
padding: 4rem 1.5rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-cta {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
newsletter/posts/.gitignore
vendored
Normal file
4
newsletter/posts/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Generated by `./binder newsletter fetch` — do not commit
|
||||||
|
# These are auto-generated from Buttondown API
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
Reference in New Issue
Block a user