import { defineCollection, reference } from 'astro:content'; import type { SchemaContext } from 'astro:content'; import { glob } from 'astro/loaders'; import { z } from 'astro/zod'; const safeUrl = z.string().refine( (url) => { if (url.startsWith('/')) return !url.startsWith('//'); try { const parsed = new URL(url); return ['http:', 'https:', 'mailto:'].includes(parsed.protocol); } catch { return false; } }, { message: 'URL must be an absolute http(s)/mailto URL or a root-relative path.' } ); const linkSchema = z.object({ label: z.string(), url: safeUrl, download: z.boolean().optional(), }); const thumbnailSchema = ({ image }: SchemaContext) => z.object({ src: image(), alt: z.string(), }); const mediaSchema = ({ image }: SchemaContext) => z .discriminatedUnion('type', [ z.object({ type: z.enum(['image', 'diagram']), src: image(), alt: z.string().optional(), decorative: z.boolean().optional(), caption: z.string().optional(), transcript: z.string().optional(), }), z .object({ type: z.literal('video'), poster: image().optional(), mp4: safeUrl.optional(), webm: safeUrl.optional(), alt: z.string().optional(), decorative: z.boolean().optional(), caption: z.string().optional(), transcript: z.string().optional(), }) .refine((item) => Boolean(item.mp4) || Boolean(item.webm), { message: 'Video media needs at least one mp4 or webm source.', }), ]) .refine((item) => item.decorative || (Boolean(item.alt) && Boolean(item.caption)), { message: 'Meaningful media needs both alt text and a caption.', }); const posts = defineCollection({ loader: glob({ pattern: '**/*.md', base: './src/content/posts' }), schema: ({ image }) => z.object({ title: z.string(), description: z.string().max(160), date: z.coerce.date(), updated: z.coerce.date().optional(), draft: z.boolean().default(false), thumbnail: thumbnailSchema({ image }), tags: z.array( z.enum([ 'ai', 'systems', 'graphics', 'simulation', 'embedded', 'web', 'tools', 'games', ]) ), featuredOrder: z.number().optional(), projectPeriod: z.string().optional(), role: z.string().optional(), stack: z.array(z.string()).optional(), scale: z.string().optional(), outcome: z.string().optional(), audience: z .enum(['general', 'technical', 'recruiter-relevant']) .default('technical'), links: z.array(linkSchema).default([]), media: z.array(mediaSchema({ image })).default([]), }), }); const projects = defineCollection({ loader: glob({ pattern: '**/*.md', base: './src/content/projects' }), schema: ({ image }) => z.object({ title: z.string(), description: z.string().max(160), thumbnail: thumbnailSchema({ image }), period: z.string(), sortDate: z.coerce.date(), technologies: z.array(z.string()).default([]), selected: z.boolean().default(false), essay: reference('posts').optional(), links: z.array(linkSchema).default([]), }), }); export const collections = { posts, projects };