c1c57c4f85
- node:sqlite storage in $DATA_DIR/views.db (no native deps) - POST /api/views/<slug>/ counts once per visitor per 24h via daily-salted hash; IPs are never stored, bots are skipped - GET /api/views/top/ feeds the sidebar's featured list, falling back to frontmatter-featured and latest posts when unavailable Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
86 lines
3.2 KiB
TypeScript
86 lines
3.2 KiB
TypeScript
import { getCollection, type CollectionEntry } from 'astro:content';
|
|
import { categories, type Category } from './data';
|
|
import { slugify } from './slug';
|
|
|
|
export type Post = CollectionEntry<'blog'>;
|
|
|
|
/** Published posts (drafts only in dev), newest `pubDate` first. Unknown categories fail the build. */
|
|
export async function getPosts(): Promise<Post[]> {
|
|
const posts = await getCollection('blog', ({ data }) => import.meta.env.DEV || !data.draft);
|
|
for (const post of posts) {
|
|
if (!categories.some((c) => c.slug === post.data.category)) {
|
|
throw new Error(
|
|
`Post "${post.id}" uses unknown category "${post.data.category}" (see src/data/categories.json)`,
|
|
);
|
|
}
|
|
}
|
|
return posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
|
}
|
|
|
|
export const postUrl = (post: Post) => `/blog/${post.id}/`;
|
|
export const categoryUrl = (slug: string) => `/blog/kategori/${slug}/`;
|
|
export const tagUrl = (tag: string) => `/blog/etiket/${slugify(tag)}/`;
|
|
export const tagSlug = (tag: string) => slugify(tag);
|
|
|
|
export function categoryOf(post: Post): Category {
|
|
return categories.find((c) => c.slug === post.data.category)!;
|
|
}
|
|
|
|
/** ~200 words per minute on the raw Markdown body (code blocks included). */
|
|
export function readingMinutes(post: Post): number {
|
|
const words = (post.body ?? '').split(/\s+/).filter(Boolean).length;
|
|
return Math.max(1, Math.round(words / 200));
|
|
}
|
|
|
|
export function categoryCounts(posts: Post[]) {
|
|
return categories
|
|
.map((category) => ({
|
|
category,
|
|
count: posts.filter((p) => p.data.category === category.slug).length,
|
|
}))
|
|
.filter((c) => c.count > 0);
|
|
}
|
|
|
|
export interface TagCount {
|
|
tag: string;
|
|
slug: string;
|
|
count: number;
|
|
}
|
|
|
|
/** Tags grouped by slug (first spelling wins), most used first. */
|
|
export function tagCounts(posts: Post[]): TagCount[] {
|
|
const map = new Map<string, TagCount>();
|
|
for (const tag of posts.flatMap((p) => p.data.tags)) {
|
|
const slug = slugify(tag);
|
|
const entry = map.get(slug) ?? { tag, slug, count: 0 };
|
|
entry.count++;
|
|
map.set(slug, entry);
|
|
}
|
|
return [...map.values()].sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag, 'tr'));
|
|
}
|
|
|
|
/** Fallback "featured" list: frontmatter `featured` first, topped up with the latest posts. */
|
|
export function featuredPosts(posts: Post[], limit = 3): Post[] {
|
|
const featured = posts.filter((p) => p.data.featured);
|
|
return [...featured, ...posts.filter((p) => !p.data.featured)].slice(0, limit);
|
|
}
|
|
|
|
/** prev = older post, next = newer post. */
|
|
export function adjacentPosts(posts: Post[], post: Post) {
|
|
const i = posts.findIndex((p) => p.id === post.id);
|
|
return { prev: posts[i + 1], next: i > 0 ? posts[i - 1] : undefined };
|
|
}
|
|
|
|
/** Chunks a list into pages; always at least one (possibly empty) page. */
|
|
export function chunk<T>(items: T[], size: number): T[][] {
|
|
const pages: T[][] = [];
|
|
for (let i = 0; i < items.length; i += size) pages.push(items.slice(i, i + size));
|
|
return pages.length ? pages : [[]];
|
|
}
|
|
|
|
/** Page 1 lives at the base URL, others at `<base>sayfa/<n>/`. */
|
|
export const pageUrl = (base: string, n: number) => (n <= 1 ? base : `${base}sayfa/${n}/`);
|
|
|
|
/** `[...page]` param value for page n (undefined = page 1). */
|
|
export const pageParam = (n: number) => (n <= 1 ? undefined : `sayfa/${n}`);
|