import { readdirSync, readFileSync, existsSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { parse } from '@rferl/veronica'; const CONTENT_DIR = resolve('public/stories'); // -- Types ------------------------------------------------------------------ export type ContainerWidth = | 'narrower' | 'narrow' | 'normal' | 'wide' | 'wider' | 'widest' | 'fluid'; export interface AnimationConfig { inView?: boolean; margin?: string; amount?: 'some' | 'all' | number; initial?: Record; animate?: Record; transition?: { duration?: number; delay?: number }; } export interface TextBlock { type: 'text'; value: string; animation?: AnimationConfig; } export interface PhotoBlock { type: 'photo'; src: string; alt: string; caption?: string; width?: ContainerWidth; animation?: AnimationConfig; } export interface GraphicBlock { type: 'graphic'; title?: string; description?: string; notes?: string; width?: ContainerWidth; animation?: AnimationConfig; } export type BodyBlock = TextBlock | PhotoBlock | GraphicBlock; export interface EndNote { title: string; text: string; } export interface StoryMeta { slug: string; headline: string; dek?: string; section?: string; publishTime: string; updateTime?: string; authors: string[]; coverImage?: string; coverAlt?: string; } export interface StoryData extends StoryMeta { body: BodyBlock[]; endNotes: EndNote[]; } // -- Helpers ---------------------------------------------------------------- interface FreeformBlock { type: string; value: string | Record; [key: string]: unknown; } function parseAnimationConfig(raw: Record | undefined): AnimationConfig | undefined { if (!raw) return undefined; const inView = raw.inView ?? raw['animation.inView']; if (inView === undefined && !raw.margin && !raw['animation.margin'] && !raw.initial && !raw.animate) { return undefined; } const margin = (raw.margin ?? raw['animation.margin']) as string | undefined; const amount = (raw.amount ?? raw['animation.amount']) as 'some' | 'all' | number | undefined; const initial = (raw.initial ?? raw['animation.initial']) as Record | undefined; const animate = (raw.animate ?? raw['animation.animate']) as Record | undefined; const transition = (raw.transition ?? raw['animation.transition']) as | { duration?: number; delay?: number } | undefined; return { inView: inView === true || inView === 'true', margin, amount, initial, animate, transition, }; } function getBlockAnimation(rawBlock: FreeformBlock): AnimationConfig | undefined { const nested = rawBlock.animation as Record | undefined; if (nested && typeof nested === 'object') { return parseAnimationConfig(nested); } return parseAnimationConfig(rawBlock as unknown as Record); } function parseAuthors(raw: string | undefined): string[] { if (!raw) return []; return raw .split(',') .map((a) => a.trim()) .filter(Boolean); } function resolveImagePath(slug: string, src: string): string { if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('/')) { return src; } return `/stories/${slug}/${src}`; } function parseRaw(slug: string, raw: Record): StoryData { const body: BodyBlock[] = []; const rawBody = (raw.body ?? []) as FreeformBlock[]; for (const block of rawBody) { const animation = getBlockAnimation(block); switch (block.type) { case 'text': body.push({ type: 'text', value: String(block.value ?? ''), animation }); break; case 'photo': { const v = block.value as Record; body.push({ type: 'photo', src: resolveImagePath(slug, v.src ?? ''), alt: v.alt ?? '', caption: v.caption, width: (v.width as ContainerWidth) ?? undefined, animation, }); break; } case 'graphic': { const v = block.value as Record; body.push({ type: 'graphic', title: v.title, description: v.description, notes: v.notes, width: (v.width as ContainerWidth) ?? undefined, animation, }); break; } default: if (block.type) { console.warn(`Unknown body block type: ${block.type}`); } } } const endNotes: EndNote[] = ((raw.endNotes ?? []) as Record[]).map((n) => ({ title: n.title ?? '', text: n.text ?? '', })); const coverImage = raw.coverImage ? resolveImagePath(slug, raw.coverImage as string) : undefined; return { slug, headline: (raw.headline as string) ?? 'Untitled', dek: raw.dek as string | undefined, section: raw.section as string | undefined, publishTime: (raw.publishTime as string) ?? new Date().toISOString(), updateTime: (raw.updateTime as string) || undefined, authors: parseAuthors(raw.authors as string | undefined), coverImage, coverAlt: (raw.coverAlt as string) || undefined, body, endNotes, }; } // -- Public API ------------------------------------------------------------- export function listStories(): StoryMeta[] { if (!existsSync(CONTENT_DIR)) return []; const slugs = readdirSync(CONTENT_DIR, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => d.name); const stories: StoryMeta[] = []; for (const slug of slugs) { const amlPath = join(CONTENT_DIR, slug, `${slug}.aml`); if (!existsSync(amlPath)) continue; const text = readFileSync(amlPath, 'utf-8'); const raw = parse(text) as Record; const data = parseRaw(slug, raw); stories.push({ slug: data.slug, headline: data.headline, dek: data.dek, section: data.section, publishTime: data.publishTime, updateTime: data.updateTime, authors: data.authors, coverImage: data.coverImage, coverAlt: data.coverAlt, }); } return stories.sort( (a, b) => new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime() ); } export function loadStory(slug: string): StoryData | null { const amlPath = join(CONTENT_DIR, slug, `${slug}.aml`); if (!existsSync(amlPath)) return null; const text = readFileSync(amlPath, 'utf-8'); const raw = parse(text) as Record; return parseRaw(slug, raw); } export function getAllSlugs(): string[] { if (!existsSync(CONTENT_DIR)) return []; return readdirSync(CONTENT_DIR, { withFileTypes: true }) .filter((d) => d.isDirectory() && existsSync(join(CONTENT_DIR, d.name, `${d.name}.aml`))) .map((d) => d.name); }