initial commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user