/** * Typed, validated access to src/data/*.json. * A schema mismatch throws at import time, so `astro build` fails loudly. */ import { z } from 'astro/zod'; import siteJson from '../data/site.json'; import socialJson from '../data/social.json'; import homeJson from '../data/home.json'; import categoriesJson from '../data/categories.json'; import blogJson from '../data/blog.json'; import aboutJson from '../data/about.json'; import contactJson from '../data/contact.json'; import pagesJson from '../data/pages.json'; const link = z.object({ label: z.string().min(1), href: z.string().min(1) }); const icon = z.string().startsWith('tabler:'); const siteSchema = z.object({ name: z.string(), title: z.string(), description: z.string().max(160), url: z.url(), lang: z.string(), locale: z.string(), titleSeparator: z.string(), author: z.object({ name: z.string(), callsign: z.string(), locator: z.string() }), postsPerPage: z.number().int().positive(), nav: z.array(link).min(1), footer: z.object({ about: z.string(), quickLinksTitle: z.string(), legalTitle: z.string(), socialTitle: z.string(), rss: link, legal: z.array(link), license: z.string(), signoff: z.string(), }), ui: z.object({ skipToContent: z.string(), mainNav: z.string(), footerNav: z.string(), menuOpen: z.string(), menuClose: z.string(), themeToggle: z.string(), newTab: z.string(), home: z.string(), }), }); const socialSchema = z.array( z.object({ id: z.string(), label: z.string(), // Empty string = account not set → hidden in the UI. href: z.union([z.url(), z.literal('')]), icon, handle: z.string().optional(), }), ); const homeSchema = z.object({ seo: z.object({ description: z.string().max(160) }), hero: z.object({ numeral: z.string(), tags: z.array(z.object({ icon, label: z.string() })), asideTitle: z.string(), asideBody: z.string(), asideLink: link, eyebrow: z.string(), title: z.string(), lead: z.string(), ctas: z.array( link.extend({ variant: z.enum(['primary', 'secondary', 'link']), icon: icon.optional() }), ), }), sections: z.array( z.discriminatedUnion('type', [ z.object({ type: z.literal('feature'), id: z.string(), eyebrow: z.string(), title: z.string(), body: z.string(), callsign: z.string().optional(), callsignLabel: z.string().optional(), icon, link: link.extend({ href: z.url() }), }), z.object({ type: z.literal('cards'), id: z.string(), eyebrow: z.string(), title: z.string(), items: z .array( z.object({ title: z.string(), description: z.string().optional(), href: z.url(), icon, }), ) .min(1), }), z.object({ type: z.literal('latestPosts'), id: z.string(), eyebrow: z.string(), title: z.string(), count: z.number().int().positive(), allLink: link, empty: z.string(), }), ]), ), }); const categoriesSchema = z .array( z.object({ slug: z.string().regex(/^[a-z0-9-]+$/), name: z.string(), description: z.string(), icon, }), ) .min(1); /** Object of required string keys; keeps the key names in the inferred type. */ const strings = (keys: readonly K[]) => z.object(Object.fromEntries(keys.map((k) => [k, z.string()])) as Record); const blogSchema = z.object({ seo: z.object({ title: z.string(), description: z.string().max(160) }), list: strings([ 'eyebrow', 'title', 'categoryEyebrow', 'tagEyebrow', 'tagTitle', 'categoryDescription', 'tagDescription', 'pageTitle', 'empty', ]), card: strings(['readMore', 'readingTime']), dates: strings(['created', 'updated']), pager: strings(['label', 'first', 'prev', 'next', 'last', 'page']), sidebar: strings([ 'label', 'search', 'searchLabel', 'searchPlaceholder', 'searchNoResults', 'searchResults', 'searchUnavailable', 'categories', 'featured', 'tags', ]), post: strings([ 'breadcrumbLabel', 'toc', 'shareLabel', 'copyLink', 'copied', 'nativeShare', 'copyCode', 'codeCopied', 'category', 'tags', 'adjacentLabel', 'prev', 'next', 'license', 'licenseName', 'licenseHref', ]), diy: z.object({ title: z.string(), body: z.string(), link }), share: z.array( z.object({ id: z.string(), label: z.string(), icon, url: z.string().includes('{url}') }), ), }); const seo = z.object({ title: z.string().max(60), description: z.string().max(160) }); const aboutSchema = z.object({ seo, eyebrow: z.string(), title: z.string(), lead: z.string(), identity: z.object({ title: z.string(), items: z.array(z.object({ label: z.string(), value: z.string(), icon })), }), equipment: z.array( z.object({ title: z.string(), icon, items: z.array(z.object({ name: z.string(), note: z.string().optional() })).min(1), }), ), }); const contactSchema = z.object({ seo, eyebrow: z.string(), title: z.string(), icon, lead: z.string(), infoTitle: z.string(), map: z.object({ title: z.string(), lat: z.number().min(-90).max(90), lng: z.number().min(-180).max(180), zoom: z.number().int().min(1).max(19), popup: z.string(), ariaLabel: z.string(), note: z.string(), loading: z.string(), }), items: z.array( z.object({ label: z.string(), icon, values: z.array(z.string()).optional(), href: z.url().optional(), email: z.email().optional(), }), ), }); const pagesSchema = z.object({ legal: strings(['eyebrow', 'updated', 'notice']), notFound: z.object({ seoTitle: z.string(), description: z.string(), eyebrow: z.string(), title: z.string(), lead: z.string(), links: z.array(link.extend({ variant: z.enum(['primary', 'secondary', 'link']), icon })), }), }); function parse(name: string, schema: z.ZodType, data: unknown): T { const result = schema.safeParse(data); if (!result.success) { throw new Error(`src/data/${name} is invalid:\n${z.prettifyError(result.error)}`); } return result.data; } export const site = parse('site.json', siteSchema, siteJson); export const home = parse('home.json', homeSchema, homeJson); export const categories = parse('categories.json', categoriesSchema, categoriesJson); export const blog = parse('blog.json', blogSchema, blogJson); export const about = parse('about.json', aboutSchema, aboutJson); export const contact = parse('contact.json', contactSchema, contactJson); export const pages = parse('pages.json', pagesSchema, pagesJson); export const social = parse('social.json', socialSchema, socialJson).filter((s) => s.href); export type Site = z.infer; export type HomeSection = (typeof home.sections)[number]; export type Category = (typeof categories)[number]; export type SocialLink = (typeof social)[number]; /** Fills `{key}` placeholders in UI strings from the JSON files. */ export function fmt(template: string, values: Record): string { return template.replace(/\{(\w+)\}/g, (m, k: string) => (k in values ? String(values[k]) : m)); }