|

Typsichere Inhalte mit Contentful und Zod (Teil 6/7)

Fast fertig! Die Seite steht, Inhalte fließen von Contentful, Deployments sind automatisiert. Aber es gibt eine Lücke im Sicherheitsnetz.

Wenn jemand in Contentful einen Blogbeitrag ohne Excerpt oder mit einem fehlerhaften Datum veröffentlicht, könnte der Build leise kaputte Seiten erzeugen. Wäre es nicht schön, wenn der Build dir das einfach… sagen würde?

Hier kommt Zod ins Spiel.

Das Problem

Contentful bietet Typ-Generierung über Tools wie cf-content-types-generator. Diese Typen sind nützlich, aber sie beschreiben nur die Form der Daten — sie validieren nichts zur Laufzeit. TypeScript-Typen verschwinden nach der Kompilierung.

Du kannst also ein perfekt typisiertes IBlogPostFields-Interface haben und trotzdem null bekommen, wo du einen String erwartet hast. CMS-Inhalte sind Benutzereingaben. Und Benutzereingaben lügen.

Was Zod hinzufügt

Zod gibt dir Schemas, die Daten zur Laufzeit validieren. Wenn die Daten nicht passen, bekommst du einen klaren Fehler statt eines stillen Problems:

import { z } from 'zod'

const BlogPostSchema = z.object({
  title: z.string().min(1),
  slug: z.string().regex(/^[a-z0-9-]+$/),
  date: z.string().datetime(),
  excerpt: z.string().max(300),
  tags: z.array(z.string()).min(1),
  tagsColor: z.string().regex(/^#[0-9a-fA-F]{6}$/),
  draft: z.boolean(),
  featuredImage: z.object({
    fields: z.object({
      title: z.string(),
      file: z.object({
        url: z.string(),
        contentType: z.string()
      })
    })
  }).optional()
})

type BlogPost = z.infer<typeof BlogPostSchema>

Deine Typen und deine Validierung sind jetzt dasselbe. Einmal definieren, überall verwenden.

Contentful-Antworten validieren

So würde ich die Datenabruf-Schicht aktualisieren:

// src/lib/contentful.ts
import contentful from 'contentful'
import { z } from 'zod'

const client = contentful.createClient({
  space: import.meta.env.CONTENTFUL_SPACE_ID,
  accessToken: import.meta.env.CONTENTFUL_DELIVERY_TOKEN,
  host: 'cdn.contentful.com'
})

const BlogPostSchema = z.object({
  title: z.string().min(1),
  slug: z.string().regex(/^[a-z0-9-]+$/),
  date: z.string(),
  excerpt: z.string(),
  body: z.any(),
  tags: z.array(z.string()).min(1),
  tagsColor: z.string().regex(/^#[0-9a-fA-F]{6}$/),
  draft: z.boolean().default(false),
  featuredImage: z.any().optional()
})

const ProjectSchema = z.object({
  title: z.string().min(1),
  slug: z.string(),
  description: z.string(),
  technologies: z.array(z.string()),
  url: z.string().url().optional().or(z.literal('')),
  image: z.any().optional(),
  order: z.number()
})

const PageSchema = z.object({
  title: z.string().min(1),
  slug: z.string(),
  body: z.any(),
  seoDescription: z.string().max(160).optional()
})

// Typen abgeleitet von Schemas
type BlogPost = z.infer<typeof BlogPostSchema>
type Project = z.infer<typeof ProjectSchema>
type Page = z.infer<typeof PageSchema>

export { type BlogPost, type Project, type Page }

Sicheres Parsen

Statt den Contentful-Antworten blind zu vertrauen, parsen:

function validateEntry<T>(schema: z.ZodSchema<T>, entry: any, label: string): T {
  const result = schema.safeParse(entry.fields)

  if (!result.success) {
    console.error(`Validierung fehlgeschlagen für ${label}:`)
    result.error.issues.forEach(issue => {
      console.error(`  → ${issue.path.join('.')}: ${issue.message}`)
    })
    throw new Error(`Ungültiger Inhalt: ${label}`)
  }

  return result.data
}

Jetzt werden die Fetcher zu:

export async function getBlogPosts() {
  const entries = await client.getEntries({
    content_type: 'blogPost',
    order: ['-fields.date'],
    'fields.draft': false
  })

  return entries.items.map(entry =>
    validateEntry(BlogPostSchema, entry, `Blog Post: ${entry.fields.title}`)
  )
}

export async function getBlogPost(slug: string) {
  const entries = await client.getEntries({
    content_type: 'blogPost',
    'fields.slug': slug,
    include: 10,
    limit: 1
  })

  const entry = entries.items[0]
  if (!entry) return null

  return validateEntry(BlogPostSchema, entry, `Blog Post: ${slug}`)
}

export async function getProjects() {
  const entries = await client.getEntries({
    content_type: 'project',
    order: ['fields.order']
  })

  return entries.items.map(entry =>
    validateEntry(ProjectSchema, entry, `Project: ${entry.fields.title}`)
  )
}

export async function getPage(slug: string) {
  const entries = await client.getEntries({
    content_type: 'page',
    'fields.slug': slug,
    limit: 1
  })

  const entry = entries.items[0]
  if (!entry) return null

  return validateEntry(PageSchema, entry, `Page: ${slug}`)
}

Einen Blogbeitrag ohne Tags veröffentlicht? Der Build schlägt fehl mit einer klaren Meldung:

Validierung fehlgeschlagen für Blog Post: Mein Entwurf:
  → tags: Array must contain at least 1 element(s)

Viel besser als ein kryptisches Cannot read property 'map' of undefined in Produktion.

Hart abbrechen oder sanft ignorieren?

Für eine persönliche Seite würde ich hart abbrechen. Wenn Inhalte ungültig sind, sollte der Build stoppen. Besser dort fangen als kaputte Seiten ausliefern.

Aber wenn du sanfter sein willst — ungültige Einträge überspringen statt abzustürzen:

export async function getBlogPosts() {
  const entries = await client.getEntries({
    content_type: 'blogPost',
    order: ['-fields.date'],
    'fields.draft': false
  })

  const valid = []
  for (const entry of entries.items) {
    const result = BlogPostSchema.safeParse(entry.fields)
    if (result.success) {
      valid.push(result.data)
    } else {
      console.warn(`Überspringe "${entry.fields.title}": ${result.error.message}`)
    }
  }
  return valid
}

Rich-Text-Struktur validieren

Rich Text ist der knifflige Teil. Den vollen AST zu validieren wäre zu aufwändig. Pragmatisch:

const RichTextSchema = z.object({
  nodeType: z.literal('document'),
  content: z.array(z.object({
    nodeType: z.string(),
    content: z.any().optional(),
    data: z.any().optional()
  })).min(1)
})

Das bestätigt: es ist ein Rich-Text-Dokument mit mindestens einem Inhaltselement. Nicht perfekt, aber fängt den häufigsten Fall: leerer Body.

Schemas projektübergreifend wiederverwenden

Behalte Schemas an einem Ort:

// src/schemas/contentful.ts
import { z } from 'zod'

export const BlogPostSchema = z.object({ /* ... */ })
export const ProjectSchema = z.object({ /* ... */ })
export const PageSchema = z.object({ /* ... */ })

export type BlogPost = z.infer<typeof BlogPostSchema>
export type Project = z.infer<typeof ProjectSchema>
export type Page = z.infer<typeof PageSchema>

Importiere sie in deiner Daten-Schicht, deinen Komponenten, überall. Eine einzige Quelle der Wahrheit für Typen und Validierung.

Das Gesamtbild

Hier ist, was in der gesamten Serie passiert ist:

TeilWas wir behandelt haben
1Warum Astro statt Hugo
2Contentful-Setup
3Astro + Contentful verbinden
4Build-Optimierung
5Docker + Nginx
6Zod-Validierung

Fast fertig. Noch einer — Teil 7 behandelt die sichere Handhabung von Secrets in Docker-Builds. Weil API-Tokens als Build-Args zu übergeben funktioniert, aber aus Sicherheitssicht nicht ideal ist.

Weiter voran und genieße jeden Schritt deiner Coding-Reise.