Skip to content

Latest commit

 

History

523 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ZenML Website (zenml.io)

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)

Quick Start

Prerequisites

Run locally

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 runtime

Other commands

pnpm 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 formatter

Tip: pnpm build generates ~2,000 lines of output (one per page). Pipe through tail -20 to see just the result.

Environment Variables

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.

Common Workflows

LLMOpsDB ingest from llmops-db-notion

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 build

Adding a Blog Post

With 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:

  1. Create a branch: git checkout -b blog/<slug>
  2. Create src/content/blog/<slug>.md with valid frontmatter (see template below)
  3. Upload hero image to R2 (see Uploading Images)
  4. Create any new author/tag .md files if needed
  5. Validate: pnpm validate:content && pnpm check
  6. Build to confirm: pnpm build
  7. 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 .md files (NOT .mdx) — MDX breaks on Webflow-exported HTML
  • slug must match the filename
  • mainImage.url must be an absolute R2 URL
  • 14 fixed categories (don't create new ones), 118+ tags (create if needed)
  • Set draft: false when ready to publish

Uploading Images

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 YAML

With 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.

Editing Marketing Copy

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.

Making Code Changes

  1. Run pnpm dev for hot-reload development
  2. 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
  3. Open a PR. Eligible same-repository PRs get an isolated, inactive Worker preview after the required checks pass; forks run checks without credentials.

Project Structure

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)

Content Architecture

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.

Major Collections

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.).

Content Patterns

  • Reference resolution: getEntry('authors', slug) — slugs match .md filenames
  • 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

Key Patterns

Preact Islands

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.

Styling

  • Tailwind v4 with @theme block in src/styles/global.css (not tailwind.config.js)
  • .prose class for styling raw HTML from Webflow-migrated content
  • <style is:global> scoped under a parent class for styling inside Preact islands

SEO

  • All templates pass seo prop to BaseLayout via SEOProps in src/lib/seo.ts
  • Canonical URLs strip trailing slashes and .html suffixes
  • JSON-LD injected via <JsonLd data={...} slot="head" />
  • RSS feeds at /blog/rss.xml and /llmops-database/rss.xml

Layouts

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

Forms

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().

Deployment & Branch Workflow

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 checks job 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 main can 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 main can 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 main accepts 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

Push permissions

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.

Tech Stack

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

Useful to Know

  • .md not .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 in astro.config.ts to match Webflow behavior. Canonicals strip trailing /.
  • pnpm build is verbose — ~2,000 lines of output. Use tail or run in background to check the final result.
  • public/ files don't validate at build time — Astro won't error if public/images/foo.svg is 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 full https://assets.zenml.io/... URLs.
  • Migration artifacts — This site was migrated from Webflow in February 2026. Some content files have a webflow metadata block and the .prose CSS class exists for styling Webflow-exported HTML. Both are harmless legacy artifacts.

Further Reading

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)

Used by

Contributors

Languages