hypnagaga/src/lib/content.ts
Ben Aultowski 8887690d1c
Some checks failed
Publish preview / build (push) Has been cancelled
Release / release (push) Has been cancelled
Release / notify-downstream (push) Has been cancelled
Here before
2026-02-17 11:13:11 -05:00

245 lines
6.7 KiB
TypeScript

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<string, unknown>;
animate?: Record<string, unknown>;
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<string, string>;
[key: string]: unknown;
}
function parseAnimationConfig(raw: Record<string, unknown> | 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<string, unknown> | undefined;
const animate = (raw.animate ?? raw['animation.animate']) as Record<string, unknown> | 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<string, unknown> | undefined;
if (nested && typeof nested === 'object') {
return parseAnimationConfig(nested);
}
return parseAnimationConfig(rawBlock as unknown as Record<string, unknown>);
}
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<string, unknown>): 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<string, string>;
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<string, string>;
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<string, string>[]).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<string, unknown>;
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<string, unknown>;
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);
}