The zenml.io marketing website — an Astro static site hosted on Cloudflare Pages. ~2,200 pages across 21 content collections, built in ~33 seconds.
Markets two sub-products under one paid umbrella (ZenML Pro):
- ZenML — ML workflow orchestration
- Kitaru — durable runtime for AI agents (folded in May 2026; see
MERGE_PLAN.md)
pnpm install
pnpm dev # Dev server at http://localhost:4321
pnpm build # Production build (~2,200 pages)
pnpm preview # Serve dist/ through the local Cloudflare Workers runtimepnpm check # Astro + TypeScript checks for site source
pnpm check:tests # Type-check tests, Vitest config, and dist smoke script
pnpm check:surface # Verify pages/components declare their analytics surface
pnpm check:alt # Verify image alt-text coverage
pnpm check:blog-covers # Verify every blog post points at its content/blog/<slug>/ AVIF + JPEG cover
pnpm lint # Biome linter
pnpm test # Run Vitest once
pnpm build # Production build (~2,200 pages)
pnpm smoke:dist # Smoke-test dist/ after pnpm build
pnpm check:worker # Exercise dist/ through the production-format Worker
pnpm check:worker-bindings -- metadata.json
# Verify a candidate version has both form secrets
pnpm validate:content # Content schema validation (Zod)
pnpm validate:llmops # LLMOps collection-focused validation
pnpm lint:fix # Auto-fix lint issues
pnpm format # Biome formatterTip:
pnpm buildgenerates ~2,000 lines of output (one per page). Pipe throughtail -20to see just the result.
The site builds and runs locally without any env vars. All content and asset URLs are committed to git. Variables are only needed for upload tooling and server-side API routes.
Copy .env.example to .env and fill in what you need:
| Variable | When needed | Purpose |
|---|---|---|
CLOUDFLARE_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY |
Uploading images to R2 | scripts/r2-upload.py |
SEGMENT_FORMS_WRITE_KEY |
Form submissions (production) | Server-side Segment tracking |
TURNSTILE_SECRET_KEY |
Form submissions (production) | Server-side bot verification |
GITHUB_TOKEN, GITHUB_API_TOKEN |
Optional | Higher GitHub API limits for the stars endpoint |
CLOUDFLARE_DNS_TOKEN |
DNS audit scripts (rare) | Internal tooling only |
The .env file is gitignored and safe for secrets.
New LLMOpsDB entries can now be published directly from the Notion pipeline into:
src/content/llmops-database/*.md
The publishing happens in the sibling llmops-db-notion repo. After it writes new or updated .md files here, validate with:
pnpm validate:llmops
pnpm check
pnpm buildWith Claude Code: Say "new blog post" or "add blog" — the blog-post-contributor skill handles everything: branch creation, frontmatter, image processing (AVIF conversion + R2 upload), tag/author validation, and PR setup. See .claude/skills/blog-post-contributor/SKILL.md for the full workflow. Cover images come from the figma-blog-cover skill (Figma template → R2).
Manually:
- Create a branch:
git checkout -b blog/<slug> - Create
src/content/blog/<slug>.mdwith valid frontmatter (see template below) - Upload hero image to R2 (see Uploading Images)
- Create any new author/tag
.mdfiles if needed - Validate:
pnpm validate:content && pnpm check - Build to confirm:
pnpm build - Commit, push, open PR
Minimal frontmatter:
---
title: "Your Blog Post Title"
slug: "your-blog-post-slug"
draft: true
author: "author-slug"
date: "2026-02-26T00:00:00.000Z"
mainImage:
url: "https://assets.zenml.io/content/blog/xxxxxxxx/hero.avif"
alt: "Description of the hero image"
seo:
title: "Your Blog Post Title - ZenML Blog"
description: "A concise 150-160 character description."
canonical: "https://www.zenml.io/blog/your-blog-post-slug"
---Key rules:
- Use
.mdfiles (NOT.mdx) — MDX breaks on Webflow-exported HTML slugmust match the filenamemainImage.urlmust be an absolute R2 URL- 14 fixed categories (don't create new ones), 118+ tags (create if needed)
- Set
draft: falsewhen ready to publish
Images live in one of two places:
| Where | What goes there | How to reference |
|---|---|---|
public/images/ |
Site-wide UI (logos, icons, backgrounds) | "/images/filename.svg" |
| Cloudflare R2 | Content images (blog heroes, screenshots, team photos) | "https://assets.zenml.io/..." |
Rule of thumb: If it's in src/content/*.md frontmatter → R2 (schemas require absolute URLs). If it's site furniture → public/images/.
Upload to R2:
# Requires R2 credentials in .env
uv run scripts/r2-upload.py path/to/image.avif
uv run scripts/r2-upload.py path/to/hero.webp --prefix content/blog
uv run scripts/r2-upload.py path/to/hero.webp --frontmatter # paste-ready YAMLWith Claude Code: Say "upload image" — the r2-image-upload skill handles it.
Warning: Astro does NOT error when a public/images/ file is missing — it silently 404s at runtime. Always verify files exist after adding references.
Marketing page text is centralized in typed data files, not hardcoded in templates:
| Page | Data file |
|---|---|
| Homepage | src/lib/homepage.ts |
| Company | src/lib/company.ts |
| Navigation | src/lib/labs-home.ts |
| Footer | src/lib/footer.ts |
Edit the data file, not the .astro template. Components import from these files.
- Run
pnpm devfor hot-reload development - Before opening a PR, run:
pnpm check && pnpm check:tests && pnpm check:surface && pnpm check:alt && pnpm check:blog-covers && pnpm lint && pnpm test && pnpm build && pnpm smoke:dist && pnpm check:worker && pnpm check:islands - Open a PR. Eligible same-repository PRs get an isolated, inactive Worker preview after the required checks pass; forks run checks without credentials.
src/
├── components/
│ ├── islands/ # Preact interactive components (client:load)
│ │ ├── filter-index/ # LlmopsIndex, MlopsIndex, BlogIndex, IntegrationsIndex
│ │ ├── BlogSearch.tsx
│ │ ├── ContactForm.tsx
│ │ ├── CookieConsent.tsx
│ │ ├── DemoRequestForm.tsx
│ │ ├── FeatureTabsSlider.tsx
│ │ ├── ProTestimonialCarousel.tsx
│ │ └── RoiCalculator.tsx
│ ├── sections/ # Homepage + shared section components
│ ├── seo/ # JsonLd, meta tag components
│ └── *.astro # Shared components (Button, Nav, Footer, etc.)
├── content/ # 21 content collections (~2,410 .md files)
│ ├── blog/ # 290+ blog posts (incl. Kitaru-origin)
│ ├── compare-kitaru/ # 8 Kitaru-vs-X compare pages (the only .mdx collection)
│ ├── llmops-database/ # 1,453 LLMOps entries
│ ├── integrations/ # 68 integrations
│ ├── compare/ # 17 VS comparison pages
│ └── ... # 16 more collections (tags, categories, authors, etc.)
├── layouts/
│ ├── BaseLayout.astro # Main layout (nav, footer, analytics, head slots)
│ ├── BlogLayout.astro # Blog posts (conditional TOC sidebar)
│ └── MinimalLayout.astro # No nav/footer (for embeds, iframes)
├── lib/ # Shared data + utilities
│ ├── homepage.ts # Homepage copy, stats, URLs, FAQ
│ ├── labs-home.ts # Nav structure (typed) + Labs homepage copy
│ ├── footer.ts # Footer structure (typed)
│ ├── seo.ts # SEO utilities (resolveSeo, buildCanonical)
│ ├── llmops.ts # LLMOps domain layer
│ └── constants.ts # SITE_URL, ASSET_BASE_URL
├── pages/ # File-based routing (~45 static + CMS templates)
│ ├── api/ # Server-side API routes (prerender: false)
│ └── ... # 30+ routes
├── styles/
│ └── global.css # Tailwind v4 @theme block + design tokens
└── content.config.ts # All 20 collection schemas (Zod validation)
public/
├── images/ # Static images (logos, backgrounds)
├── lottie/ # Lottie animation JSON
├── _headers # Cloudflare security headers
├── _redirects # 52 redirect rules (Webflow URL preservation)
└── llms.txt # LLM-readable site description
scripts/
└── r2-upload.py # Upload images to R2 (see "Uploading Images")
design/ # Heavy artifacts + internal docs (gitignored)
All content lives in src/content/ as .md files with YAML frontmatter. Astro's Content Layer API loads them via glob loaders defined in src/content.config.ts.
| Collection | Items | Route | Notes |
|---|---|---|---|
| Blog | 280 | /blog/[slug] |
Paginated hub (12/page), categories, tags, authors |
| LLMOps Database | 1,453 | /llmops-database/[slug] |
Faceted sidebar, Pagefind search, AND/OR filtering |
| Integrations | 68 | /integrations/[slug] |
Hub grid + structured detail pages |
| Compare (VS) | 17 | /compare/[slug] |
ZenML vs X comparison pages |
| Feature Pages | 12 | /features/[slug] |
Discriminated union blocks for flexible sections |
| Case Studies | 5 | /case-study/[slug] |
Body-driven layout with sidebar |
| Team | 22 | /team/[slug] |
Team member profiles |
Plus 13 supporting collections (tags, categories, authors, integration types, etc.).
- Reference resolution:
getEntry('authors', slug)— slugs match.mdfilenames - Blog pagination: 12 posts/page,
/blog→/blog/page/2→/blog/page/24 - Feature page blocks:
z.discriminatedUnion('kind', [...])for flexible section ordering - Marketing copy: Centralized in
src/lib/{page}.ts, not hardcoded in templates
Interactive components use Astro's islands architecture. Only components that need client-side JS are hydrated:
<LlmopsIndex client:load tags={tags} industries={industries} />Islands in src/components/islands/ — the filter-index/ family (LlmopsIndex, MlopsIndex, BlogIndex, IntegrationsIndex), BlogSearch, ContactForm, CookieConsent, DemoRequestForm, FeatureTabsSlider, ProTestimonialCarousel, RoiCalculator.
- Tailwind v4 with
@themeblock insrc/styles/global.css(nottailwind.config.js) .proseclass for styling raw HTML from Webflow-migrated content<style is:global>scoped under a parent class for styling inside Preact islands
- All templates pass
seoprop toBaseLayoutviaSEOPropsinsrc/lib/seo.ts - Canonical URLs strip trailing slashes and
.htmlsuffixes - JSON-LD injected via
<JsonLd data={...} slot="head" /> - RSS feeds at
/blog/rss.xmland/llmops-database/rss.xml
| Layout | Use for |
|---|---|
BaseLayout |
Full page shell (nav, footer, analytics, cookie consent) |
BlogLayout |
Blog posts (extends BaseLayout, conditional TOC sidebar) |
MinimalLayout |
Bare HTML shell for Storylane embeds and iframes |
ContactForm Preact island → Astro API route at src/pages/api/forms/[formType].ts → Segment HTTP API (identify + track). Server-side, fire-and-forget via ctx.waitUntil().
Production remains on Cloudflare Pages until the separately approved Worker cutover. CI now builds and validates one Worker artifact, then publishes that exact artifact for trusted upload workflows. It does not automatically update the live Pages project during this migration checkpoint.
- All PRs → the credential-free
Repo checksjob builds and validates one artifact, including the Astro 5 Wrangler runtime and island hydration checks - Same-repository PRs → after explicit review, a manual workflow defined
on
maincan upload that exact artifact as an inactive, unreachable version of the isolated preview Worker - Fork and Dependabot PRs → checks only; no Cloudflare credentials or upload
- Approved branch candidate → a manual workflow defined on
maincan upload an exact successful CI artifact to the production Worker with public preview URLs disabled; it does not activate the version - Activation → a separate manual workflow defined on
mainaccepts an exact version ID and provenance. Running it requires a separate decision; attaching the production route remains a later cutover step - Preview responses retain
X-Robots-Tag: noindex - Analytics are hostname-gated to
www.zenml.io(preview traffic excluded) - Squash merge only — each PR becomes a single commit on main
- Branches are auto-deleted after merge
| Who | Direct push to main? |
|---|---|
strickvl, htahir1 |
Yes (bypass branch protection) |
| Everyone else | Must use PRs (1 approval required) |
PRs require the Repo checks status check to pass (pnpm check,
pnpm check:tests, pnpm check:surface, pnpm check:alt, pnpm lint,
pnpm test, pnpm build, pnpm smoke:dist, pnpm check:worker, and
pnpm check:islands). The trusted Worker upload runs separately and is not a
merge gate. See docs/worker-release-runbook.md for the artifact, preview,
candidate, activation, and rollback metadata flow.
See docs/branch-protection-spec.md for the full governance spec.
| Layer | Technology |
|---|---|
| Framework | Astro v5 (TypeScript, static-first, content collections) |
| Content | Markdown (.md) files in git with Zod-validated schemas |
| Styling | Tailwind CSS v4 (utility-first, @theme design tokens) |
| Interactive | Preact islands (only hydrate what needs JS) |
| Hosting | Cloudflare Pages in production; Cloudflare Workers migration candidate |
| Assets | Cloudflare R2 (object storage for images/files) |
| Forms | Preact ContactForm island → Astro API routes → Segment |
| Analytics | Plausible + GA4 + Segment (hostname-gated) |
| Search | Pagefind (build-time full-text index) |
| Code highlighting | Shiki (github-dark theme, build-time) |
| Linting | Biome v2 |
.mdnot.mdx— Content files must use.md. MDX v2 treats all HTML as strict JSX, which breaks Webflow-exported HTML (multi-line tables, curly braces, mixed HTML/markdown).- Trailing slash:
never— Configured inastro.config.tsto match Webflow behavior. Canonicals strip trailing/. pnpm buildis verbose — ~2,000 lines of output. Usetailor run in background to check the final result.public/files don't validate at build time — Astro won't error ifpublic/images/foo.svgis referenced but missing. It silently 404s at runtime.- Content image URLs must be absolute — Content collection schemas use
z.string().url(), so images in frontmatter need fullhttps://assets.zenml.io/...URLs. - Migration artifacts — This site was migrated from Webflow in February 2026. Some content files have a
webflowmetadata block and the.proseCSS class exists for styling Webflow-exported HTML. Both are harmless legacy artifacts.
| Resource | What's in it |
|---|---|
CLAUDE.md |
Project conventions, architecture decisions, gotchas (read this if using Claude Code) |
AGENTS.md |
Repository contribution guidelines (mirrors CLAUDE.md, shorter) |
MERGE_PLAN.md |
ZenML × Kitaru merge plan, phase log, decisions log |
docs/MIGRATION.md |
How the site was migrated from Webflow (Feb 2026) |
docs/kitaru-seo-inventory.md |
Phase 10a SEO inventory + redirect audit template |
docs/branch-protection-spec.md |
Branch protection rules and reviewer configuration |
.claude/skills/ |
Claude Code automation skills (blog posts, image uploads, Figma blog covers) |
.env.example |
All available environment variables with documentation |
src/content.config.ts |
All 21 content collection schemas (Zod) |