Files
ta1sph.info/src/lib/data.ts
T
suphicakir df9c40847d feat: add about, contact with map, legal pages and 404
- 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>
2026-10-16 01:59:45 +03:00

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));
}