initial commit

This commit is contained in:
2026-06-17 22:37:47 +05:30
commit bc31b2508b
100 changed files with 18050 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
import type { Article, Author, Category } from './types';
import { imageUrl } from './payload';
export function formatDate(d: string | Date, opts: Intl.DateTimeFormatOptions = {}): string {
const date = typeof d === 'string' ? new Date(d) : d;
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
...opts,
}).format(date);
}
export function readingMinutes(article: Article): number {
if (article.readingMinutes) return article.readingMinutes;
const body = article.body as { root?: { children?: unknown[] } } | null;
const n = JSON.stringify(body ?? '').split(/\s+/).length;
return Math.max(1, Math.round(n / 220));
}
export function categoryOf(article: Article): Category | null {
return typeof article.category === 'object' ? article.category : null;
}
export function tagsOf(article: Article) {
return Array.isArray(article.tags) ? article.tags.filter((t) => typeof t === 'object') : [];
}
export function authorOf(article: Article): Author | null {
return typeof article.author === 'object' ? article.author : null;
}
export function avatarOf(author: Author | null): string | null {
if (!author) return null;
return imageUrl(author.avatar);
}
export function coverOf(
article: Article,
size: 'card' | 'hero' | 'thumbnail' = 'card',
): string | null {
const cover = article.coverImage;
if (!cover || typeof cover === 'string') return imageUrl(cover);
const sized = cover.sizes?.[size]?.url;
if (sized) {
return sized.startsWith('http')
? sized
: `${(cover.sizes as Record<string, { url: string }>)[size]?.url}`;
}
return imageUrl(cover);
}
+14
View File
@@ -0,0 +1,14 @@
import { convertLexicalToHTML, defaultHTMLConverters } from '@payloadcms/richtext-lexical/html';
import type { SerializedEditorState } from 'lexical';
export type LexicalDoc = SerializedEditorState | unknown;
export function lexicalToHtml(doc: LexicalDoc): string {
if (!doc || typeof doc !== 'object') return '';
const data = doc as SerializedEditorState;
try {
return convertLexicalToHTML({ data, converters: defaultHTMLConverters });
} catch (err) {
return `<p class="text-error">Failed to render content: ${(err as Error).message}</p>`;
}
}
+241
View File
@@ -0,0 +1,241 @@
import type { Article, Author, Category, Edition } from './types';
const API = (import.meta.env.PAYLOAD_API_INTERNAL ||
import.meta.env.PUBLIC_PAYLOAD_API ||
'http://localhost:3000') as string;
const ORIGIN = (import.meta.env.PUBLIC_SITE_URL || 'http://localhost:4321') as string;
interface ListOpts {
limit?: number;
page?: number;
sort?: string;
where?: Record<string, unknown>;
depth?: number;
draft?: boolean;
}
function qs(opts: ListOpts = {}): string {
const sp = new URLSearchParams();
if (opts.limit) sp.set('limit', String(opts.limit));
if (opts.page) sp.set('page', String(opts.page));
if (opts.sort) sp.set('sort', opts.sort);
if (opts.depth) sp.set('depth', String(opts.depth));
if (opts.draft) sp.set('draft', 'true');
if (opts.where) sp.set('where', JSON.stringify(opts.where));
const s = sp.toString();
return s ? `?${s}` : '';
}
async function get<T>(path: string, opts: ListOpts = {}): Promise<T> {
let res: Response;
try {
res = await fetch(`${API}${path}${qs(opts)}`, {
headers: { Accept: 'application/json' },
});
} catch (err) {
if (isBuildTime() && process.env.PAYLOAD_BUILD_TOLERANT !== 'false') {
console.warn(`[payload] ${path} unreachable at build time: ${(err as Error).message}`);
return emptyResult() as T;
}
throw new Error(`Payload unreachable for ${path}: ${(err as Error).message}`);
}
if (!res.ok) {
if (isBuildTime() && process.env.PAYLOAD_BUILD_TOLERANT !== 'false') {
console.warn(`[payload] ${path} returned ${res.status} at build time`);
return emptyResult() as T;
}
throw new Error(`Payload ${res.status} ${res.statusText} for ${path}`);
}
return (await res.json()) as T;
}
function isBuildTime(): boolean {
return process.env.NODE_ENV === 'production' || process.env.ASTRO_BUILD === '1';
}
function emptyResult(): Paginated<unknown> {
return { docs: [], totalDocs: 0, totalPages: 0, page: 1, hasNextPage: false, hasPrevPage: false };
}
export const payloadOrigin = () => ORIGIN;
export const payloadApiOrigin = () => API;
export interface Paginated<T> {
docs: T[];
totalDocs: number;
totalPages: number;
page: number;
hasNextPage: boolean;
hasPrevPage: boolean;
}
const PUBLISHED = { status: { equals: 'published' } };
export async function getCategories(): Promise<Category[]> {
const r = await get<Paginated<Category>>('/api/categories', { limit: 100, sort: 'order' });
return r.docs;
}
export async function getCategoryBySlug(slug: string): Promise<Category | null> {
const r = await get<Paginated<Category>>('/api/categories', {
where: { and: [{ slug: { equals: slug } }] },
limit: 1,
depth: 1,
});
return r.docs[0] ?? null;
}
export async function getAuthors(): Promise<Author[]> {
const r = await get<Paginated<Author>>('/api/authors', { limit: 100, sort: 'name' });
return r.docs;
}
export async function getAuthorBySlug(slug: string): Promise<Author | null> {
const r = await get<Paginated<Author>>('/api/authors', {
where: { and: [{ slug: { equals: slug } }] },
limit: 1,
depth: 1,
});
return r.docs[0] ?? null;
}
export async function getArticles(
opts: {
limit?: number;
page?: number;
categorySlug?: string;
tagSlug?: string;
featured?: boolean;
} = {},
): Promise<Paginated<Article>> {
const where: Record<string, unknown>[] = [PUBLISHED];
if (opts.categorySlug) where.push({ 'category.slug': { equals: opts.categorySlug } });
if (opts.tagSlug) where.push({ 'tags.slug': { equals: opts.tagSlug } });
if (opts.featured) where.push({ featured: { equals: true } });
return get<Paginated<Article>>('/api/articles', {
limit: opts.limit ?? 24,
page: opts.page ?? 1,
sort: '-publishedAt',
depth: 1,
where: { and: where },
});
}
export async function getArticleBySlug(slug: string): Promise<Article | null> {
const r = await get<Paginated<Article>>('/api/articles', {
where: { and: [PUBLISHED, { slug: { equals: slug } }] },
limit: 1,
depth: 2,
});
return r.docs[0] ?? null;
}
export async function getAllArticleSlugs(): Promise<string[]> {
const r = await get<Paginated<Article>>('/api/articles', {
limit: 1000,
sort: '-publishedAt',
where: PUBLISHED,
});
return r.docs.map((a) => a.slug);
}
export async function getRelated(article: Article, limit = 3): Promise<Article[]> {
const cat = typeof article.category === 'object' ? article.category.slug : article.category;
const r = await get<Paginated<Article>>('/api/articles', {
limit,
sort: '-publishedAt',
where: {
and: [PUBLISHED, { 'category.slug': { equals: cat } }, { id: { not_equals: article.id } }],
},
});
return r.docs;
}
export async function getPopular(limit = 3): Promise<Article[]> {
const r = await get<Paginated<Article>>('/api/articles', {
limit,
sort: '-publishedAt',
where: PUBLISHED,
});
return r.docs;
}
export async function getEditorsPicks(limit = 3): Promise<Article[]> {
const r = await get<Paginated<Article>>('/api/articles', {
limit,
where: { and: [PUBLISHED, { featured: { equals: true } }] },
sort: 'featuredRank',
});
return r.docs;
}
export async function search(q: string): Promise<{ articles: Article[]; authors: Author[] }> {
if (!q || q.length < 2) return { articles: [], authors: [] };
const like = { like: q };
let ar: Paginated<Article> = emptyResult() as Paginated<Article>;
let au: Paginated<Author> = emptyResult() as Paginated<Author>;
try {
[ar, au] = await Promise.all([
get<Paginated<Article>>('/api/articles', {
limit: 12,
sort: '-publishedAt',
where: { and: [PUBLISHED, { or: [{ title: like }, { excerpt: like }] }] },
}),
get<Paginated<Author>>('/api/authors', {
limit: 6,
where: { or: [{ name: like }, { role: like }] },
}),
]);
} catch (e) {
console.warn(`[payload] search("${q}") failed: ${(e as Error).message}`);
}
return { articles: ar.docs, authors: au.docs };
}
export async function getLatestEdition(): Promise<Edition | null> {
try {
const r = await get<Paginated<Edition>>('/api/editions', {
limit: 1,
sort: '-publishedAt',
depth: 1,
});
return r.docs[0] ?? null;
} catch {
return null;
}
}
export interface SubscribeResult {
ok: boolean;
message: string;
}
export async function subscribe(email: string, source = 'web'): Promise<SubscribeResult> {
const url = `${API}/api/subscribers`;
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ email, source }),
});
if (res.ok) return { ok: true, message: 'Subscribed. See you Tuesday.' };
const data = (await res.json().catch(() => null)) as { errors?: { message: string }[] } | null;
const msg = data?.errors?.[0]?.message || 'Could not subscribe. Try again.';
return { ok: false, message: msg };
} catch {
return { ok: false, message: 'Network error. Try again.' };
}
}
export function imageUrl(m: unknown): string | null {
if (!m) return null;
if (typeof m === 'string') return m;
if (typeof m === 'object' && m && 'url' in m) {
const url = (m as { url: string }).url;
if (url.startsWith('http')) return url;
return `${API}${url}`;
}
return null;
}
+43
View File
@@ -0,0 +1,43 @@
const SITE = (import.meta.env.PUBLIC_SITE_URL || 'https://softvowels.example') as string;
export interface SeoInput {
title: string;
description?: string;
image?: string | null;
url?: string;
type?: 'website' | 'article';
publishedTime?: string;
author?: string;
tags?: string[];
}
export function seo(input: SeoInput) {
const title = input.title.endsWith('· SoftVowels') ? input.title : `${input.title} · SoftVowels`;
const description =
input.description ?? 'Engineering-focused insights for the modern software architect.';
const url = input.url ?? SITE;
const image = input.image ?? `${SITE}/og-default.png`;
return {
title,
description,
canonical: url,
openGraph: {
title,
description,
url,
siteName: 'SoftVowels',
images: [{ url: image }],
locale: 'en_US',
type: input.type ?? 'website',
...(input.publishedTime ? { publishedTime: input.publishedTime } : {}),
...(input.author ? { authors: [input.author] } : {}),
...(input.tags?.length ? { tags: input.tags } : {}),
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [image],
},
};
}
+81
View File
@@ -0,0 +1,81 @@
export interface Media {
id: string;
url: string;
alt?: string;
width?: number;
height?: number;
mimeType?: string;
sizes?: {
thumbnail?: { url: string; width: number; height: number };
card?: { url: string; width: number; height: number };
hero?: { url: string; width: number; height: number };
};
}
export interface Author {
id: string;
name: string;
slug: string;
role?: string;
bio?: string;
avatar?: Media | string | null;
socials?: {
twitter?: string;
github?: string;
site?: string;
};
}
export interface Category {
id: string;
name: string;
slug: string;
description?: string;
icon?: string;
color?: string;
order?: number;
}
export interface Tag {
id: string;
name: string;
slug: string;
}
export interface Article {
id: string;
title: string;
slug: string;
excerpt?: string;
body: unknown; // Lexical SerializedEditorState — rendered via lib/lexical.ts
coverImage?: Media | string | null;
category: Category | string;
tags?: (Tag | string)[];
author: Author | string;
status: 'draft' | 'published';
publishedAt: string;
readingMinutes?: number;
featured?: boolean;
featuredRank?: number;
seo?: {
title?: string;
description?: string;
ogImage?: Media | string | null;
};
}
export interface Edition {
id: string;
number: number;
title: string;
intro?: string;
publishedAt: string;
cover?: Media | string | null;
articles?: (Article | string)[];
}
export type Resolved<T> = T extends string | number | null | undefined
? T
: T extends object
? { [K in keyof T]: Resolved<T[K]> }
: T;