mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-03-09 07:15:51 -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.ind
|
||||
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.bib import BibCommand
|
||||
from cli.commands.render import RenderCommand
|
||||
from cli.commands.newsletter import NewsletterCommand
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -64,6 +65,7 @@ class MLSysBookCLI:
|
||||
self.info_command = InfoCommand(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.newsletter_command = NewsletterCommand(self.config_manager, verbose=verbose)
|
||||
|
||||
def show_banner(self):
|
||||
"""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("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
|
||||
mgmt_table = Table(show_header=True, header_style="bold blue", box=None)
|
||||
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(full_table, title="📚 Full Book Commands", border_style="blue"))
|
||||
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"))
|
||||
|
||||
# Pro Tips
|
||||
@@ -368,6 +384,10 @@ class MLSysBookCLI:
|
||||
"""Handle render command group (plots)."""
|
||||
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):
|
||||
"""Handle debug command.
|
||||
@@ -463,6 +483,7 @@ class MLSysBookCLI:
|
||||
"info": self.handle_info_command,
|
||||
"bib": self.handle_bib_command,
|
||||
"render": self.handle_render_command,
|
||||
"newsletter": self.handle_newsletter_command,
|
||||
"setup": self.handle_setup_command,
|
||||
"hello": self.handle_hello_command,
|
||||
"about": self.handle_about_command,
|
||||
|
||||
@@ -60,6 +60,10 @@ website:
|
||||
- icon: lightbulb
|
||||
text: "Labs (Coming 2026)"
|
||||
href: labs/
|
||||
- text: "---"
|
||||
- icon: envelope
|
||||
text: "Newsletter"
|
||||
href: newsletter/
|
||||
right:
|
||||
- icon: download
|
||||
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