feat: add blog with archives, pagination, search, sharing and RSS
- Content collection (one folder per post) with zod schema - /blog, category and tag archives with /sayfa/<n>/ pagination - Post page: breadcrumbs, date badges, share bar, TOC, DIY notice, code copy, license line, prev/next, BlogPosting + BreadcrumbList JSON-LD - Sidebar: Pagefind search, categories, featured fallback, tag cloud - Sätteri HAST plugins for figure/figcaption and external links - Dual-theme Shiki, themed prose, RSS feed, latest posts on home - Three sample posts marked as examples Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@ import { z } from 'astro/zod';
|
||||
import siteJson from '../data/site.json';
|
||||
import socialJson from '../data/social.json';
|
||||
import homeJson from '../data/home.json';
|
||||
import categoriesJson from '../data/categories.json';
|
||||
import blogJson from '../data/blog.json';
|
||||
|
||||
const link = z.object({ label: z.string().min(1), href: z.string().min(1) });
|
||||
const icon = z.string().startsWith('tabler:');
|
||||
@@ -111,6 +113,73 @@ const homeSchema = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
const categoriesSchema = z
|
||||
.array(
|
||||
z.object({
|
||||
slug: z.string().regex(/^[a-z0-9-]+$/),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
icon,
|
||||
}),
|
||||
)
|
||||
.min(1);
|
||||
|
||||
/** Object of required string keys; keeps the key names in the inferred type. */
|
||||
const strings = <const K extends string>(keys: readonly K[]) =>
|
||||
z.object(Object.fromEntries(keys.map((k) => [k, z.string()])) as Record<K, z.ZodString>);
|
||||
|
||||
const blogSchema = z.object({
|
||||
seo: z.object({ title: z.string(), description: z.string().max(160) }),
|
||||
list: strings([
|
||||
'eyebrow',
|
||||
'title',
|
||||
'categoryEyebrow',
|
||||
'tagEyebrow',
|
||||
'tagTitle',
|
||||
'categoryDescription',
|
||||
'tagDescription',
|
||||
'pageTitle',
|
||||
'empty',
|
||||
]),
|
||||
card: strings(['readMore', 'readingTime']),
|
||||
dates: strings(['created', 'updated']),
|
||||
pager: strings(['label', 'first', 'prev', 'next', 'last', 'page']),
|
||||
sidebar: strings([
|
||||
'label',
|
||||
'search',
|
||||
'searchLabel',
|
||||
'searchPlaceholder',
|
||||
'searchNoResults',
|
||||
'searchResults',
|
||||
'searchUnavailable',
|
||||
'categories',
|
||||
'featured',
|
||||
'tags',
|
||||
]),
|
||||
post: strings([
|
||||
'breadcrumbLabel',
|
||||
'toc',
|
||||
'shareLabel',
|
||||
'copyLink',
|
||||
'copied',
|
||||
'nativeShare',
|
||||
'copyCode',
|
||||
'codeCopied',
|
||||
'category',
|
||||
'tags',
|
||||
'adjacentLabel',
|
||||
'prev',
|
||||
'next',
|
||||
'license',
|
||||
'licenseName',
|
||||
'licenseHref',
|
||||
]),
|
||||
diy: z.object({ title: z.string(), body: z.string(), link }),
|
||||
share: z.array(
|
||||
z.object({ id: z.string(), label: z.string(), icon, url: z.string().includes('{url}') }),
|
||||
),
|
||||
});
|
||||
|
||||
function parse<T>(name: string, schema: z.ZodType<T>, data: unknown): T {
|
||||
const result = schema.safeParse(data);
|
||||
if (!result.success) {
|
||||
@@ -121,8 +190,16 @@ function parse<T>(name: string, schema: z.ZodType<T>, data: unknown): T {
|
||||
|
||||
export const site = parse('site.json', siteSchema, siteJson);
|
||||
export const home = parse('home.json', homeSchema, homeJson);
|
||||
export const categories = parse('categories.json', categoriesSchema, categoriesJson);
|
||||
export const blog = parse('blog.json', blogSchema, blogJson);
|
||||
export const social = parse('social.json', socialSchema, socialJson).filter((s) => s.href);
|
||||
|
||||
export type Site = z.infer<typeof siteSchema>;
|
||||
export type HomeSection = (typeof home.sections)[number];
|
||||
export type Category = (typeof categories)[number];
|
||||
export type SocialLink = (typeof social)[number];
|
||||
|
||||
/** Fills `{key}` placeholders in UI strings from the JSON files. */
|
||||
export function fmt(template: string, values: Record<string, string | number>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (m, k: string) => (k in values ? String(values[k]) : m));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export type DateKind = 'created' | 'updated';
|
||||
export interface DateBadge {
|
||||
kind: DateKind;
|
||||
date: Date;
|
||||
}
|
||||
|
||||
const TZ = 'Europe/Istanbul';
|
||||
const dayKey = (d: Date) => new Intl.DateTimeFormat('en-CA', { timeZone: TZ }).format(d);
|
||||
|
||||
/**
|
||||
* Which date leads: the newer one is primary (filled badge), the other secondary (outline).
|
||||
* No / same-day / older update → only the created date is shown.
|
||||
*/
|
||||
export function dateBadges(
|
||||
pub: Date,
|
||||
upd?: Date,
|
||||
): { primary: DateBadge; secondary: DateBadge | null } {
|
||||
if (!upd || dayKey(upd) === dayKey(pub) || upd < pub) {
|
||||
return { primary: { kind: 'created', date: pub }, secondary: null };
|
||||
}
|
||||
return { primary: { kind: 'updated', date: upd }, secondary: { kind: 'created', date: pub } };
|
||||
}
|
||||
|
||||
const longFormat = new Intl.DateTimeFormat('tr-TR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: TZ,
|
||||
});
|
||||
|
||||
export const formatDate = (d: Date) => longFormat.format(d);
|
||||
/** Value for `<time datetime>` (calendar day in Istanbul). */
|
||||
export const isoDay = (d: Date) => dayKey(d);
|
||||
@@ -0,0 +1,60 @@
|
||||
// Sätteri (Astro 7 default Markdown processor) HAST plugins.
|
||||
// Plain JS because astro.config.mjs imports it directly.
|
||||
|
||||
const SITE_HOST = 'ta1sph.info';
|
||||
|
||||
const isWhitespace = (n) => n.type === 'text' && !n.value.trim();
|
||||
|
||||
/**
|
||||
* `` alone in a paragraph → `<figure><img><figcaption>Caption</figcaption></figure>`.
|
||||
* Images without a title stay as they are.
|
||||
*/
|
||||
export const figurePlugin = {
|
||||
name: 'ta1sph-figure',
|
||||
element: {
|
||||
filter: ['p'],
|
||||
visit(node, ctx) {
|
||||
const content = node.children.filter((c) => !isWhitespace(c));
|
||||
if (content.length !== 1) return;
|
||||
const img = content[0];
|
||||
if (img.type !== 'element' || img.tagName !== 'img') return;
|
||||
const caption = img.properties?.title;
|
||||
if (typeof caption !== 'string' || !caption.trim()) return;
|
||||
const { title: _title, ...props } = img.properties;
|
||||
ctx.replaceNode(node, {
|
||||
type: 'element',
|
||||
tagName: 'figure',
|
||||
properties: {},
|
||||
children: [
|
||||
{ ...img, properties: props },
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'figcaption',
|
||||
properties: {},
|
||||
children: [{ type: 'text', value: caption }],
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Absolute links to other sites open in a new tab with a safe rel. */
|
||||
export const externalLinksPlugin = {
|
||||
name: 'ta1sph-external-links',
|
||||
element: {
|
||||
filter: ['a'],
|
||||
visit(node, ctx) {
|
||||
const href = node.properties?.href;
|
||||
if (typeof href !== 'string' || !/^https?:\/\//i.test(href)) return;
|
||||
try {
|
||||
const host = new URL(href).hostname.replace(/^www\./, '');
|
||||
if (host === SITE_HOST) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
ctx.setProperty(node, 'target', '_blank');
|
||||
ctx.setProperty(node, 'rel', ['noopener', 'noreferrer']);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
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`, else the latest posts. */
|
||||
export function featuredPosts(posts: Post[], limit = 3): Post[] {
|
||||
const featured = posts.filter((p) => p.data.featured);
|
||||
return (featured.length ? featured : posts).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}`);
|
||||
Reference in New Issue
Block a user