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:
2026-10-16 01:06:07 +03:00
parent f2d943c31b
commit 809f89af08
27 changed files with 1599 additions and 27 deletions
+77
View File
@@ -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));
}