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:
Vijay Janapa Reddi
2026-03-07 17:22:52 -05:00
parent 0b021d407f
commit aa0c690a6f
14 changed files with 854 additions and 0 deletions

4
.gitignore vendored
View File

@@ -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

View 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

View File

@@ -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,

View File

@@ -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"

View 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
View 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: |
&copy; 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> &middot; <a href="https://mlsysbook.ai/vol2/">Volume II</a> &middot; <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">

View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
# Generated by `./binder newsletter fetch` — do not commit
# These are auto-generated from Buttondown API
*
!.gitignore