245 lines
6.7 KiB
TypeScript
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);
|
|
}
|