import { defineCollection } from 'astro:content'; import type { SchemaContext } from 'astro:content'; import { glob } from 'astro/loaders'; import { z } from 'astro/zod'; function isRootRelativeUrl(url: string) { return url.startsWith('/') && !url.startsWith('//'); } const linkUrl = z.string().refine( (url) => { if (isRootRelativeUrl(url)) return true; try { const parsed = new URL(url); return ['https:', 'mailto:'].includes(parsed.protocol); } catch { return false; } }, { message: 'URL must be an absolute https/mailto URL or a root-relative path.' } ); const mediaUrl = z.string().refine( (url) => { if (isRootRelativeUrl(url)) return true; try { return new URL(url).protocol === 'https:'; } catch { return false; } }, { message: 'Media URL must be an absolute https URL or a root-relative path.' } ); function isIframeUrl(url: string) { if (isRootRelativeUrl(url)) return true; try { return new URL(url).protocol === 'https:'; } catch { return false; } } export const TAGS = [ 'ai', 'systems', 'graphics', 'simulation', 'embedded', 'web', 'tools', 'games', ] as const; const linkSchema = z.object({ label: z.string(), url: linkUrl, download: z.boolean().optional(), }); const thumbnailSchema = ({ image }: SchemaContext) => z.object({ src: image(), alt: z.string().min(1, 'Thumbnail alt text must not be empty.'), }); // Partial thumbnail used by the project card to override just the image, just // the alt text, or both. Anything omitted falls back to the entry's top-level // thumbnail, so a card that only rewords its alt doesn't repeat the `src`. const thumbnailOverrideSchema = ({ image }: SchemaContext) => z.object({ src: image().optional(), alt: z.string().min(1, 'Thumbnail alt text must not be empty.').optional(), }); 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: mediaUrl.optional(), webm: mediaUrl.optional(), captions: mediaUrl.optional(), captionsLabel: z.string().default('English captions'), 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.', }) .refine( (item) => item.type !== 'video' || item.decorative || Boolean(item.captions), { message: 'Meaningful video needs captions.', } ) .refine( (item) => item.type !== 'video' || item.decorative || Boolean(item.transcript), { message: 'Meaningful video needs a transcript.', } ); // A single collection where each entry can carry an `article` facet (a written // page under /articles/), a `project` facet (a card in the /projects/ // index), or both. Fields shared by both facets live at the top level and are // written once; the few where the project card deliberately differs from the // article are overridden inside `project`. The markdown body is the article's // content. const articleFacet = ({ image }: SchemaContext) => z.object({ updated: z.coerce.date().optional(), draft: z.boolean().default(false), iframeThumbnail: z.boolean().default(false), featuredOrder: z.number().optional(), tags: z.array(z.enum(TAGS)).default([]), 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'), media: z.array(mediaSchema({ image })).default([]), }); const projectFacet = ({ image }: SchemaContext) => z.object({ selected: z.boolean().default(false), // Card chips. When omitted, the card falls back to the article's `stack`. technologies: z.array(z.string()).optional(), // Card overrides. When omitted, the card uses the top-level value. title: z.string().optional(), description: z.string().max(160).optional(), thumbnail: thumbnailOverrideSchema({ image }).optional(), }); const work = defineCollection({ loader: glob({ pattern: '**/*.md', base: './src/content/work' }), schema: ({ image }) => z .object({ // Shared identity (single source of truth). title: z.string(), description: z.string().max(160), thumbnail: thumbnailSchema({ image }), period: z.string().optional(), date: z.coerce.date(), links: z.array(linkSchema).default([]), article: articleFacet({ image }).optional(), project: projectFacet({ image }).optional(), }) .refine((entry) => Boolean(entry.article) || Boolean(entry.project), { message: 'An entry needs at least an `article` or a `project` facet.', }) .refine( (entry) => !entry.article?.iframeThumbnail || entry.links.some( (link) => !link.download && link.label.trim().toLowerCase() === 'demo' && isIframeUrl(link.url) ), { path: ['article', 'iframeThumbnail'], message: 'iframeThumbnail requires a non-download Demo link with an https or root-relative URL.', } ), }); export const collections = { work };