df9c40847d
- About page with identity cards and equipment lists (about.json) - Contact page: station info, obfuscated e-mail, lazy Leaflet/OSM map with antenna tower marker and dark-mode tiles - Privacy, license and disclaimer pages from a legal collection - QRT-themed 404 page - LICENSE (MIT) and LICENSE-CONTENT (CC BY-SA 4.0 legal code) - Shared PageHeader and CodeCopy components Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
272 lines
7.3 KiB
TypeScript
272 lines
7.3 KiB
TypeScript
/**
|
|
* 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 = <const K extends string>(keys: readonly K[]) =>
|
|
z.object(Object.fromEntries(keys.map((k) => [k, z.string()])) as Record<K, z.ZodString>);
|
|
|
|
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<T>(name: string, schema: z.ZodType<T>, 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<typeof siteSchema>;
|
|
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, string | number>): string {
|
|
return template.replace(/\{(\w+)\}/g, (m, k: string) => (k in values ? String(values[k]) : m));
|
|
}
|